Part 1 of this project showed us how to create the basic structure and styling of our pinboard, but static HTML and CSS can only get us so far. The last step in creating a fully functional pinboard is to add interactivity with JavaScript. Here is what we will cover now:
We'll be using JavaScript to control and create dynamic content, so we can remove our hardcoded elements from our basic structure.
We added a defer
attribute to our script
tag in our HTML. Since we are no longer hardcoding our pins in the HTML, we have to wait for the HTML to be created before our JavaScript runs. This means that there might be a brief delay before JavaScript loads the dynamic content. While we wait, we will only be able to see the HTML and CSS. We might want to display a loading animation so users know the content is still loading, so let's add the following CSS to our style.css
file:
@keyframes spin {
0% { transform: rotate(0deg) }
100% { transform: rotate(360deg) }
}
.loader {
animation: spin 0.6s linear 0s infinite;
display: block;
border: 8px solid #80008030;
border-top: 8px solid purple;
border-radius: 50%;
width: 6rem;
height: 6rem;
margin: 6rem auto;
}
The "strange" syntax in the snippet above is a way of declaring animations in CSS. The declared animation (via @keyframes
) is telling our styling that our animated element should start a 0 degrees rotation and continue all the way to 360 degrees rotation. We are also binding the animation to our .loader
class using the animation
property. Our animation
property describes behaviour in this order:
spin
animation declared by means of the @keyframe
at-rule.0%
to 100%
) should last 0.6
seconds.linear
, meaning it moves at the same speed, continually.0
seconds before starting.infinite
).The HTML element with the loader
class will be an exact square, with a height
and width
of 6rem
. When we apply a border-radius
of 50%
, the element gets turned into a circle. This circle should not have a background colour but should have a light-pink border but where one edge is dark purple (by overriding with border-top
). By spinning this circle on its own axis (as per the animation
), we create our loading effect.
Once the loader is added, we can replace our placeholder pins with the HTML below. You should replace the entire original <main>
element and its content in your HTML:
<main>
<div class="list" id="pins-list">
<span class="loader"></span>
</div>
</main>
This means you will see this while our JavaScript loads (you don't have any JavaScript now, so it should be in this state indefinitely):
However, there are still some other left-overs from our hardcoded HTML in part 1. If we enter a value into the filter field (top-left), we will still get autocompleted recommendations from our previous pins (even though we have no pins or tags on the page at the moment). To fix this, we must clear the contents of our <datalist>
HTML element (since we'll be managing these via JavaScript):
You should change the current <datalist>
element to:
<datalist id="existing-tags"></datalist>
Now we are ready to add our JavaScript code. Similar to what we did in part 1, we will add the JavaScript in its entirety and then walk through it step by step. Let's start by placing the entire snippet below in our script.js
file:
let pins = [];
const defaultPins = [
{
id: "122203215486581930752615279550",
image: "https://images.unsplash.com/photo-1580983218765-f663bec07b37?w=600",
tags: ["engineering"],
},
{
id: "144685389103194178251333634000",
image: "https://images.unsplash.com/photo-1572932491814-4833690788ad?w=600",
tags: ["headphones", "ocean", "wellness"],
},
{
id: "159279541173033634211014623228",
image: "https://images.unsplash.com/photo-1580894908361-967195033215?w=600",
tags: ["office", "coding", "desk"],
},
{
id: "75261220651273643680893699100",
image: "https://images.unsplash.com/photo-1584464491033-06628f3a6b7b?w=600",
tags: ["boxing", "wellness"],
},
{
id: "161051747537834597427464147310",
image: "https://images.unsplash.com/photo-1581094271901-8022df4466f9?w=600",
tags: ["lab", "engineering"],
},
];
const savedPins= localStorage.getItem('savedPins');
if (savedPins) {
pins = JSON.parse(savedPins)
} else {
pins = defaultPins;
}
const existingTagsNode = document.querySelector('#existing-tags');
const filterInputNode = document.querySelector('#filter-input');
const pinsListNode = document.querySelector('#pins-list');
const dialogNode = document.querySelector('#dialog');
const dialogStartNode = document.querySelector('#dialog-start');
const dialogFormNode = document.querySelector('#dialog-form');
const dialogImageNode = document.querySelector('#dialog-image');
const dialogTagsNode = document.querySelector('#dialog-tags');
const dialogSubmitNode = document.querySelector('#dialog-submit');
function updateHTML (providedPins) {
pinsListNode.innerHTML = (providedPins || pins).map(
({ id, image, tags }) => (`
<section class="pin">
<img class="image" src="${image}">
<ul class="info">
${tags.map(
(tag) => (`
<li class="tag-wrap">
<button class="tag">${tag}</button>
</li>
`)
).join('')}
</ul>
<button class="remove" aria-label="remove" value="${id}">
✕
</button>
</section>
`)
).join('');
}
function updatePins (newPins) {
if (newPins) pins = newPins;
localStorage.setItem('savedPins', JSON.stringify(pins))
existingTagsNode.innerHTML = pins.reduce(
(result, { tags }) => {
const newTags = tags.filter(tag => !result.includes(tag));
return [...result, ...newTags]
},
[]
).map(
(tag) => `<option>${tag[0].toUpperCase()}${tag.slice(1)}</option>`
).join('')
updateHTML();
}
function applyFilter (filter) {
if (filter.trim() === '') return updateHTML();
const array = filter
.split(',')
.map(text => text.trim())
.map(text => text.toLowerCase());
const filteredPins = pins.filter(({ tags }) => {
const matchedTags = tags.filter(tag => array.includes(tag));
return matchedTags.length >= array.length;
}
)
updateHTML(filteredPins);
}
function handleInput (event) {
if (event.target === filterInputNode) {
applyFilter(escape(event.target.value))
} else if (event.target === dialogImageNode || event.target === dialogTagsNode) {
if (dialogImageNode.value.trim() !== '' && dialogTagsNode.value.trim() !== '') {
dialogSubmitNode.disabled = false;
} else {
dialogSubmitNode.disabled = true;
}
}
}
function handleClick (event) {
if (event.target === dialogStartNode || event.target === dialogNode) {
dialogNode.classList.toggle('hidden')
dialogNode.open = !dialogNode.open;
} else if (event.target.classList.contains('remove')) {
updatePins(pins.filter(({ id }) => id !== event.target.value));
applyFilter(filterInputNode.value)
} else if (event.target.classList.contains('tag')) {
filterInputNode.value = event.target.innerText;
applyFilter(filterInputNode.value)
}
}
function handleSubmit (event) {
event.preventDefault();
const time = new Date()
.getTime()
const id = `${time}${Math.random() * 100000000000000000}`;
const image = encodeURI(dialogImageNode.value.trim());
const tags = dialogTagsNode.value
.split(',')
.map(tag => tag.trim())
.map(tag => tag.toLowerCase())
.map(tag => escape(tag));
updatePins([ ...pins, { id, image, tags } ]);
applyFilter(filterInputNode.value)
dialogNode.classList.add("hidden");
dialogNode.open = false;
dialogImageNode.value = '';
dialogTagsNode.value = '';
dialogSubmitNode.disabled = true;
}
document.body.addEventListener('input', handleInput)
document.body.addEventListener('click', handleClick)
document.body.addEventListener('submit', handleSubmit)
updatePins();
Before executing any logic, we need to set up some basic data structures. First, instead of hardcoding our pins in the HTML as before, we will now keep track of them using an array with objects in our JavaScript. Each object will contain an id
, image
and an array of tags
. However, if a user visits our page for the first time, their pins will start as an empty array ([]
). This won't look very appealing, so we also add a defaultPins
array that we can add to our active pins
array if this is the first time a user is visiting our page. The defaultPins
contains all the values that we hardcoded in part 1, but you can replace them with your own default values.
All the above JavaScript will stop running once we close the page, so any data stored in the pins
variable (whether added by a user or the default pins) will be lost. This means that the array will be created again from scratch when the user returns to their pinboard - not helpful.
Fortunately, all modern browsers allow us to persist data even after we close our pinboard. We can use the localStorage.setItem
method to save data locally to our device, and then use localStorage.getItem
to retrieve the data again when the page loads. While localStorage
is super powerful, there are a couple of things to keep in mind:
localStorage
data too.localStorage
.localStorage
.The last two points are important since it means that we are unable to store arrays or objects to localStorage
. A common way around this is to turn our data structures into strings (via JSON.stringify
) before saving it to localStorage
, and then turn it back into an array or object (via JSON.parse
) after retrieving it from localStorage
.
For example, by running JSON.stringify
on our array, we are able to save a string resembling the following in localStorage
:
"[{id:\"1222032154865\",image:\"https:\/\/images.unsplash.com\/photo-1580983218765-f663bec07b37?w=600\",tags:[\"engineering\"],},{id:\"1446853891031\",image:\"https:\/\/images.unsplash.com\/photo-1572932491814-4833690788ad?w=600\",tags:[\"headphones\",\"ocean\",\"wellness\"],},{id:\"1592795411730\",image:\"https:\/\/images.unsplash.com\/photo-1580894908361-967195033215?w=600\",tags:[\"office\",\"coding\",\"desk\"],},{id:\"752612206512\",image:\"https:\/\/images.unsplash.com\/photo-1584464491033-06628f3a6b7b?w=600\",tags:[\"boxing\",\"wellness\"],},{id:\"1610517475378\",image:\"https:\/\/images.unsplash.com\/photo-1581094271901-8022df4466f9?w=600\",tags:[\"lab\",\"engineering\"],},]"
This is how we use localStorage
in our JavaScript code:
savedPins
saved in our localStorage
.JSON.parse
on it to turn it into an array.pins
variable to the returned array. (If no such savedPins
value exists in localStorage
, we know that this is the first time a user is visiting our page.)pins
variable with the default pins:let pins = [];
const defaultPins = [
{
id: "1222032154865",
image: "https://images.unsplash.com/photo-1580983218765-f663bec07b37?w=600",
tags: ["engineering"],
},
{
id: "1446853891031",
image: "https://images.unsplash.com/photo-1572932491814-4833690788ad?w=600",
tags: ["headphones", "ocean", "wellness"],
},
{
id: "1592795411730",
image: "https://images.unsplash.com/photo-1580894908361-967195033215?w=600",
tags: ["office", "coding", "desk"],
},
{
id: "752612206512",
image: "https://images.unsplash.com/photo-1584464491033-06628f3a6b7b?w=600",
tags: ["boxing", "wellness"],
},
{
id: "1610517475378",
image: "https://images.unsplash.com/photo-1581094271901-8022df4466f9?w=600",
tags: ["lab", "engineering"],
},
];
const savedPins= localStorage.getItem('savedPins');
if (savedPins) {
pins = JSON.parse(savedPins)
} else {
pins = defaultPins;
}
In addition to keeping all our active pins in a pins
variable, it's also helpful to declare all the HTML elements that we will be using upfront. This means that when returning, you'll see all the IDs used by JavaScript grouped together. All of these HTML elements are selected by means of the document.querySelector
method. The query we use is similar to selectors in CSS, for example, #existing-tags
means that JavaScript needs to look for an HTML tag with an id
attribute of existing-tags
.
In part one, we created a couple of id
attributes in our HTML that we can use to find the required elements:
const existingTagsNode = document.querySelector('#existing-tags')
const filterInputNode = document.querySelector('#filter-input');
const pinsListNode = document.querySelector('#pins-list')
const dialogNode = document.querySelector('#dialog')
const dialogStartNode = document.querySelector('#dialog-start')
const dialogFormNode = document.querySelector('#dialog-form')
const dialogImageNode = document.querySelector('#dialog-image')
const dialogTagsNode = document.querySelector('#dialog-tags')
const dialogSubmitNode = document.querySelector('#dialog-submit');
Now that we've created our basic data structures, we'll be declaring some JavaScript functions that we can run when specific conditions are met. All of these snippets just create the functions and don't do anything until the functions are called later in our code.
Any type of interactivity on the web is only possible by directly modifying the HTML or CSS that is displayed by the user. This is done by
Let's go with option 2. We will create a low-level function that we can run each time our pins
array changes. By running this function, our HTML will be re-rendered to reflect the current state of our pins
array.
We start by referencing the pinsListNode
variable, which holds the div
HTML tag that wraps all our displayed pins. Because we made changes, it only contains a <span class="loader"></span>
HTML at the moment. Once we run our updateHTML
function, the HTML inside the div
will be overridden by a new HTML string created by the following logic:
updateHTML
function is called, an optional providedPins
array can be passed directly to it as an argument.(providedPins || pins)
which tells JavaScript to use the providedPins
argument if it is passed to the function, otherwise it should fall back to the default pins
variable declared at the top of the file..map
method, the array that was selected in the last step. The .map
method accepts a function as an argument, which we immediately pass as an arrow function. This function will be executed on every single item in our array (a pin object in our case), and will then return a new array populated with the results of each execution.id
, image
and tags
property (which we decided when we created the pins
variable above). This means that we can directly destructure them into the arrow function that we pass.${ }
. This is called interpolation.image
property retrieved directly from the object by destructuring. However, the next interpolation is an actual JavaScript expression (in this case, the result of the expression will be placed in our string where the interpolation is defined)..map
, this time over the tags array inside each pin object. We're again using interpolation to add the value dynamically to the returned HTML string.["<li class="tag-wrap"><button class="tag">engineering</button></li>", <li class="tag-wrap"><button class="tag">Wellness</button></li>", <li class="tag-wrap"><button class="tag">Coding</button></li>"]
.join('')
method. The .join
method combines all values of an array into a single string. The argument that we pass to .join
determines how the items will be divided in the final string. Since we don't want any dividers between our lines of HTML strings above, we simply pass an empty string as an argument (''
). For example, [1,2,3].join('-')
will create the string: "1-2-3"
. Likewise [1,2,3].join('')
will create "123"
.map
that provides the final value to pinsListNode.innerHTML
.function updateHTML (providedPins) {
pinsListNode.innerHTML = (providedPins || pins).map(
({ id, image, tags }) => (`
<section class="pin">
<img class="image" src="${image}">
<ul class="info">
${tags.map(
(tag) => (`
<li class="tag-wrap">
<button class="tag">${tag}</button>
</li>
`)
).join('')}
</ul>
<button class="remove" aria-label="remove" value="${id}">
✕
</button>
</section>
`)
).join('');
}
The above should create a string that looks something like the below, and is assigned as the HTML inside pinListNode
:
pinsListNode.innerHTML = `
<section class="pin">
<img
class="image"
src="https://images.unsplash.com/photo-1580983218765-f663bec07b37?w=600"
>
<ul class="info">
<li class="tag-wrap">
<button class="tag">engineering</button>
</li>
</ul>
<button class="remove"aria-label="remove" value="1222032154865">
✕
</button>
</section>
<section class="pin">
<img
class="image"
src="https://images.unsplash.com/photo-1572932491814-4833690788ad?w=600"
>
<ul class="info">
<li class="tag-wrap">
<button class="tag">headphones</button>
</li>
<li class="tag-wrap">
<button class="tag">ocean</button>
</li>
<li class="tag-wrap">
<button class="tag">wellness</button>
</li>
</ul>
<button class="remove"aria-label="remove" value="1446853891031">
✕
</button>
</section >`;
It's not enough to just update our HTML. We need to perform some higher-level tasks, too. For example, we need to save the current pins
variable to localStorage
and update our datalist
HTML (so that we get the most up-to-date autocomplete recommendations). We do this using the following function:
function updatePins (newPins) {
if (newPins) pins = newPins;
localStorage.setItem('savedPins', JSON.stringify(pins))
existingTagsNode.innerHTML = pins.reduce(
(result, { tags }) => {
const newTags = tags.filter(tag => !result.includes(tag));
return [...result, ...newTags]
},
[]
).map(
(tag) => `<option>${tag[0].toUpperCase()}${tag.slice(1)}</option>`
).join('')
updateHTML();
}
Similar to our updateHTML
function, we are able to pass a value called newPins
to this function. If a newPins
array is passed to the function, then the current pins
variable (declared at the top of the file) will be overridden with newPins
. This is a quality of life feature, because in most cases where we run newPins
, we also want to update the pins
variable.
First, the function runs JSON.stringify
on our pins
array and then overrides (or creates) the current savedPins
value in localStorage
with the string from JSON.stringify
. We then retrieve the existingTagsNode
variable (which has the element for our datalist
in the HTML) and we replace its inner HTML with the result of this logic:
pins
array and run the .reduce()
method on it. To recap, .reduce()
is similar to .map()
, and also runs a function (passed as an arrow function to reduce) on each item in the original array. However, instead of providing the item itself as the argument of the arrow function, .reduce()
provides two arguments. The first result
contains the last value returned. The next argument (which we restructure as { tags }
) is the current array item that it is looping over. This allows us to do some powerful things in JavaScript. For example, we can add all the values in an array: [1,2,3,4,5,6,7,8].reduce((result, number) => result + number), 0);
which will return 36
.tags
array from each object in our array (although the other properties still exist on the object).filter
method to create a new array that contains only the tag items that are not already in the existing result
. The .filter()
method works similar to .map()
and .reduce()
as it returns a new array, but items from the original array are only copied over if the arrow function executed on the particular item returns true
. For example [21, 9, 40, 0, 3, 11].filter(number => number < 10)
will return [9, 0, 3]
.includes()
method to determine if a tag already exists in results
. If it does, it will return true
; if not, false
.result
of our .reduce()
method by combining the newly created array with the existing result
values. If the newly created array is empty (if it has no tags or all its tags are already present in result
), then an empty array will be added to result
(ie keeping result
as is)..reduce()
, we also need to pass a second argument. This second argument determines the result
value when the reduce()
method starts. In our case, we want it be an empty array ([]
).result
of .reduce()
, we still need to wrap them in actual HTML. We do this by passing the results to a .map()
method that simply wraps them in an <options>
HTML element..toUpperCase()
on it and then interpolating the rest of the value after it. .slice(1)
extracts all characters after the first one. For example, engineering
will be converted to Engineering
..join('')
on the final array to turn it into one big HTML string.The above should replace the inner HTML inside existingTagsNode
with something like:
existingTagsNode.innerHTML = `
<option>Engineering</option>
<option>Headphones</option>
<option>Wellness</option>
<option>Ocean</option>
<option>Office</option>
<option>Coding </option>
<option>Desk</option>
<option>Boxing</option>
<option>Lab</option>
`
At the end, we automatically trigger the updateHTML
function to make sure that we are showing the correct pins.
Let's create our last core function before we move on to event handlers. This function updates the HTML being displayed to the user based on a single text value (passed directly to the function). This value will correspond to the input of the filter field in our HTML:
function applyFilter (filter) {
if (filter.trim() === '') return updateHTML();
const array = filter
.split(',')
.map(text => text.trim())
.map(text => text.toLowerCase());
const filteredPins = pins.filter(({ tags }) => {
const matchedTags = tags.filter(tag => array.includes(tag));
return matchedTags.length >= array.length;
}
)
updateHTML(filteredPins);
}
Before we do anything, we want to check if the filter
argument passed to the function is ''
. If nothing is passed to the filter, we should call the updateHTML
function without passing any arguments. This means that the function will replace the current HTML using the full default pins
array (instead of a custom filtered object). This will override any currently filtered HTML (since we are essentially saying that no filters should be applied) and display all pins. We also run .trim()
on the values passed, using filter
. This is to account for empty spaced values like " "
(which should still be considered empty).
However, if the string passed by means of filter
is not empty, we start by turning it into a variable called array
that can be looped over when comparing tags. We do this to allow users to pass chained filters into a single string by means of separating them by commas (,
), for example "Engineering, Office, Lab"
. To transform this into a useable array
value, we will:
split
on the string. This breaks the string into an array, with the argument passed being used as the point of division (essentially the opposite of .join()
). This means that our example above will be transformed into the following array: ["Engineering", " Office", " Lab"]
" Office"
is not the same as "Office"
according to JavaScript. We use .map()
and the trim()
method again to remove any whitespace around our tags. This should also get rid of random spaces added by users..map()
over the array and covert all tags to lowercase (since we are keeping everything as lowercase in our JavaScript).In addition to the above, we have created another array. This array, titled filteredPins
is a duplicate of the default pins
array, but we have removed all the objects that do not have tags that match any items in array
. To create this array, we:
filter()
method on our pins
array and pass an arrow function that automatically destructures the tags
array from each object in pins
.tags
property from the pin object..includes()
to see if it matches one of the values created in our initial array
variable above (based on the filter string that was passed to the function).filter()
will only return tags that actually match the filter array
, so we can say that if it returns 0
items (checked with .length
) then none of the tags in the object match any items in our reference array
variable. This object should not be added to our new filteredPins
array.matchingTags
array, we can say that at least one tag matches our original filter array
. This means that the object should be copied to the new filteredPins
array.filteredPins
, we run updateHTML
passing filteredPins
as the array to use (uing the providePins
parameter created in the updateHTMl
function). This means that the default pins
variable won't be used, replaced by the filtered pins array that we pass.Here, the distinction between updatePins
and the lower-level updateHTML
becomes important. The updatePins
functions also runs the updateHTML
function after it performs its own tasks, such as overriding savedPins
in localStorage
and updating the datalist
HTML. You might have wondered why we didn't just embed the updateHTML
logic directly in the updatePins
functions. Here, we see the value of being able to call updateHTML
directly (without updatePins
), since this means that we can side-step all the latter logic that changes the actual pins
data. The filters are only visual in nature, so we only want to update the HTML show to the user, while keeping our pins
data untouched. Filtering pins should not actually remove any objects from the pins
array or remove any recommendations from our datalist
. If we used updatePins
instead, then this would accidentally change the pins that were added.
Taking this approach also means that we can simply run the default updateHTML
function (without passing an argument) if the filter value changes to empty, essentially syncing up the displayed HTML with the full pins
array again.
We created three modular, low-level tasks by means of functions. These can be reused throughout our JavaScript logic and abstract away common tasks. However, at this point, we've only declared these functions so nothing will happen if we run our JavaScript up until this point. To actually use the above functions, we need to trigger them in response to actions performed by users.
This is commonly done by adding event listeners directly to HTML nodes. For example in the case of our *"Add New Image"* button, we want to remove the hidden
CSS class from our dialog element. We can do the following:
dialogStartNode.addEventListener(
'click',
() => {
dialogNode.classList.remove('hidden')
dialogNode.open = true;
}
)
This is a common approach to handling user-triggered events, but it becomes tricky if we relinquish the creation of our HTML to JavaScript itself. This is because when we recreate HTML via JavaScript (as we do with updateHTML
), we need to manually re-add each individual event listener. We also need to manually remove all previous event listeners (via removeEventListener
) before swapping out the HTML. Otherwise, as outlined by Nolan Lawson, we can cause unexpected memory leaks. This is not a problem with our example because the dialogStartNode
never gets replaced. However, when we do replace HTML, this approach introduces large amounts of overhead.
Luckily, the HTML DOM itself gives us a way around this. Most modern browsers do event propagation. This means that if an event is fired, it ripples up the entire HTML tree until it is captured or reaches the top-level <body>
element.
This means we can get around placing event listeners directly on our HTML elements by rather adding them to the highest level parent the HTML <body>
element. However, since all events in our HTML will set off the event listener added to the <body>
element, we need to be able to distinguish between events. This is easy and only requires us to look at the target
property of an event's dispatched object.
With this approach, we can create three separate functions that handle all our click
, input
and submit
events on the page. Note these functions are not the event listeners themselves, but are used to respond to the event listeners by being passed as a callback to, for example, document.body.addEventListener('input', handleInput)
.
Let's start with a piece of interaction that seems like it might require a fair bit of complexity: input
. Because things need to update real-time as our input events fire, the associated logic might be heavily nested. In fact, both cases of where we listen to input
events are actually pretty trivial because we have already done most of the work with our previous core functions. However, we need to take into account character escaping.
We allow users to enter values into our inputs without restriction, so we should prevent them from entering anything that might be harmful or break the functionality of our pinboard. For example, if a user enters console.log('You've been hacked!')
into the input, we want to prevent this value from accidentally getting executed by JavaScript as code (thereby logging "You've been hacked" to the browser console).
Going back to one of our examples at the very top where we discussed how an array can be changed into a string with JSON.stringify
(in order to save it into localStorage
), we looked at the following example:
"[{id:\"1222032154865\",image:\"https:\/\/images.unsplash.com\/photo-1580983218765-f663bec07b37?w=600\",tags:[\"engineering\"],},{id:\"1446853891031\",image:\"https:\/\/images.unsplash.com\/photo-1572932491814-4833690788ad?w=600\",tags:[\"headphones\",\"ocean\",\"wellness\"],},{id:\"1592795411730\",image:\"https:\/\/images.unsplash.com\/photo-1580894908361-967195033215?w=600\",tags:[\"office\",\"coding\",\"desk\"],},{id:\"752612206512\",image:\"https:\/\/images.unsplash.com\/photo-1584464491033-06628f3a6b7b?w=600\",tags:[\"boxing\",\"wellness\"],},{id:\"1610517475378\",image:\"https:\/\/images.unsplash.com\/photo-1581094271901-8022df4466f9?w=600\",tags:[\"lab\",\"engineering\"],},]"
You'll see that all our double quotation marks ("
) have backslashes (\
) before them. This tells JavaScript that the double quote symbol should be treated as the string character "
and not as an actual JavaScript syntax symbol. If we didn't escape the quotes, JavaScript would actually close the above string prematurely, since the "
symbol is used in JavaScript to end string declarations.
This means that JavaScript would end the string when it reaches the double quote as follows:
"[{id:"
We will be escaping some of the data provided by users, so it's important to understand exactly why we are doing this. Let's look at the function itself:
function handleInput (event) {
if (event.target === filterInputNode) {
applyFilter(escape(event.target.value))
} else if (event.target === dialogImageNode || event.target === dialogTagsNode) {
if (dialogImageNode.value.trim() !== '' && dialogTagsNode.value.trim() !== '') {
dialogSubmitNode.disabled = false;
} else {
dialogSubmitNode.disabled = true;
}
}
}
We can see that there are two types of event listeners that we are interested in:
target
is the same as the filterInputNode
input.target
is either the dialogImageNode
or dialogTagsNode
inputs.The input
event is different from the change
event as that change
only fires when a user changes the value inside input and then clicks outside it. input
is triggered even when a single character changes in our input. This means that if we type Hello!
, it would fire the input
event six times, and then when we remove the exclamation mark (!
), changing the value to Hello
, it would fire again. Whereas change
would only fire once we click away from the input
.
The actual card filtering event is simple; we check if it was the filterInputNode
that triggered input
and if so, we pass the value of the input to the applyFilter
function. However, we want to add another piece of functionality to this behaviour. Because the fields used in our dialog are empty when our page loads, we also want to set the button to add the values as a pin to disabled
. However, having a button that is indefinitely disabled is useless, so we want to check the values whenever either the image URL or entered tags change. Only once both of these are full do we enable the button. We do this by:
.trim()
.''
), we set the disabled state of the submit button to false
(allowing it to be clicked).''
when trimmed, we will either keep the button disabled or set it back to disabled.A click
event listener is one of the most common event listeners on the web. It is triggered whenever a user presses anything in our HTML (this includes touch events on mobile). Currently, there are four types of click events that we are interested in:
Add New Image"
button.x
) on top of a pinned image.We can cover all of these with the following function:
function handleClick (event) {
if (event.target === dialogStartNode || event.target === dialogNode) {
dialogNode.classList.toggle('hidden')
dialogNode.open = !dialogNode.open;
} else if (event.target.classList.contains('remove')) {
updatePins(pins.filter(({ id }) => id !== event.target.value));
applyFilter(filterInputNode.value)
} else if (event.target.classList.contains('tag')) {
filterInputNode.value = event.target.innerText;
applyFilter(filterInputNode.value)
}
}
Let's go through this function step by step:
The first two events in our list require the exact same thing: the toggling of hidden and open states of the dialog. We check if the event.target
is either dialogStartNode
or the dialogNode
itself. If so, we can simply toggle the hidden
class and set the open
attribute to the exact opposite of what it currently is (by means of a logical not operator). While the last attribute has no effect on what is shown to users, it is helpful for search engines and accessibility devices.
Then, if our target
is neither of the above, we check if the target
value contains the remove
CSS class. Since we are using the remove
class to style our deletion buttons, we can assume that the event came from one of these buttons. But how do we see which pin it came from? You may remember that we added a value
attribute to each of these buttons in our HTML. This value
attribute contains the unique id
of the object corresponding to a specific pin.
This means that we can once again use the .filter()
method and tell it to create a new array that only contains objects that do not match the supplied ID (using the value
attribute). We then pass this new array directly to updatePins
and the pin is removed from the HTML and our pins
array. After updating the pins, we also re-apply the current filter value (if there is one) so the HTML update that removed the pin does not break any current filtering condition.
Lastly, if our event is neither of these, then we can check if the target has a class of tag
. If so, then we know that we are dealing with one of the tags buttons overlaid on top of a pin (when a user hovers over a pin). This means that we can use its inner text to check the name of the tag that was clicked on, and override the current filtering input with this value. However, since we are doing this programmatically (and it is not triggered by the user), we need to manually trigger the input
event.
Lastly, we have the submit
event function. This is fired whenever a form is submitted on our page. Because we only have one form on our page, we don't need to check where the event came from. We just execute the following logic:
function handleSubmit (event) {
event.preventDefault();
const id = new Date()
.getTime()
.toString();
const image = encodeURI(dialogImageNode.value.trim());
const tags = dialogTagsNode.value
.split(',')
.map(tag => tag.trim())
.map(tag => escape(tag));
updatePins([ ...pins, { id, image, tags } ]);
applyFilter(filterInputNode.value)
dialogNode.classList.add("hidden");
dialogNode.open = false;
dialogImageNode.value = '';
dialogTagsNode.value = '';
dialogSubmitNode.disabled = true;
}
preventDefault
) that we can run on the event itself to prevent this from happening.id
value to identify this new pin added to the pins
array. We generate a unique id
value by using the current date and time. We simply get the current date and time with new Date()
and then run getTime()
on it. The latter turns the created date object into a number of milliseconds that have passed since midnight 1 January 1970 (called the unix epoch in programming)..toString()
method on our millisecond number. Although an amount of milliseconds looks like a number, when we use it as a unique ID it technically isn't a number anymore.encodeURI()
on it. Not only does encodeURI()
escape characters (eg. turning ;,/?:@&=+$#
into %3B%2C%2F%3F%3A%40%26%3D%2B%24%23
), it also does this in a way that still makes it useable as a URL.applyFilter
function, with the exception that we loop over the items afterwards and manually run the native JavaScript escape
function on each item.pins
array and adding an object to it that uses the values we created above.applyFilter
to not break any filtering that is currently applied.dialog
HTML element.We've created all the logic required by our pinboard, but if we run our JavaScript up to this point, nothing will happen. This is because we only created the required data structures and functions that will be used by JavaScript. We need to action them. We do this using four lines of code:
document.body.addEventListener('input', handleInput)
document.body.addEventListener('click', handleClick)
document.body.addEventListener('submit', handleSubmit)
updatePins();
Each line is responsible for actioning a different function:
handleInput
when users input values into any input field.handleClick
when a user clicks on anything in our HTML.handleSubmit
when a user submits a form created in our HTML.updatePins
in order to create the HTML for the pins that have been loaded by JavaScript.We've touched on many concepts and native functionality of JavaScript itself. We've explained each concept as we went.
If you want a deeper understanding of something, take a look at the Mozilla Developer Network Glossary page.
You can extend the project by starting from our example repl below. For example, you can add more advanced tagging functionality to allow the user to specify multiple tags and say whether they want to show cards that match all tags (an "AND" search) or any cards (an "OR" search).
If you want to add back-end functionality, you can add a database and use sign-up so that people can view their pins from any device, instead of only the one where they originally saved them.