I want text parts to appear, and disappear, on click.
Before the first click you can only see the banner, and no verses yet; on the first click the first verse appears, on second click the second verse appears in place of the first, and so on.
I am trying to achieve this with hiding the elements, placing them in an array, and let them display when the number of times the function gets called fits the index of the verse.
I am new to JavaScript, and don't understand the exceptions thrown. If i try to inspect my code online, I get this Exception when O try to call the function by clicking on the website:
Uncaught TypeError: Cannot read properties of null (reading 'style')
This is my code so far:
const text = document.querySelector(".banner")
document.addEventListener('click', myFunction);
const verse1 = document.querySelector(".verse1")
const verse2 = document.querySelector(".verse2")
const verse3 = document.querySelector(".verse3")
const verse4 = document.querySelector(".verse4")
const verse5 = document.querySelector(".verse5")
const verses = [verse1, verse2, verse3, verse4, verse5]
let versesLength = verses.length;
function myFunction() {
for (let i = 0; i < versesLength; i ) {
text.innerHTML = verses[i].style.display = 'block';
}
}
<div >
<script src="main.js"></script>
<img src="files/SomeLogo.jpg" alt="We are still building on our Website:-)">
</div>
<div id="verses">
<div style="display: none">Lorem Ipsum</div>
<div style="display: none">Lorem Ipsum2</div>
<div style="display: none">Lorem Ipsum3</div>
<div style="display: none">Lorem Ipsum4</div>
<div style="display: none">Lorem Ipsum5</div>
</div>
I am stuck, and clicked through similar questions for the last hours. Thanks in advance for any help
CodePudding user response:
without changing anything in the HTML, you can do something like this in javascript
const text = document.querySelector(".banner")
document.addEventListener('click', myFunction);
let verses = document.querySelector("#verses").children
let count = 0
function myFunction() {
Array.from(verses).forEach(el=> el.style.display="none")
if(count < verses.length){
verses[count].style.display = 'block'
count
if(count===verses.length) count =0
}
}
CodePudding user response:
You can remove the need for an array by giving all the verse elements the same class:
verse
. We can grab them withquerySelectorAll
.Add a data attribute to each verse to identify them.
In order to limit the number of global variables we can use a closure - in the
addEventListener
we call thehandleClick
function which initialises the count, and then returns a function that will be assigned to the listener. This is a closure. It maintains a copy of its outer lexical environment (ie variables) that it can use when it's returned.
// Cache the elements with the verse class
const banner = document.querySelector('.banner');
const verses = document.querySelectorAll('.verse');
// Call `handleClick` and assign the function it
// returns to the listener
document.addEventListener('click', handleClick());
function handleClick() {
// Initialise `count`
let count = 1;
// Return a function that maintains a
// copy of `count`
return function () {
// If the count is 5 or less
if (count < verses.length 1) {
// Remove the banner
if (count === 1) banner.remove();
// Remove the previous verse
if (count > 1) {
const selector = `[data-id="${count - 1}"]`;
const verse = document.querySelector(selector);
verse.classList.remove('show');
}
// Get the new verse
const selector = `[data-id="${count}"]`;
const verse = document.querySelector(selector);
// And show it
verse.classList.add('show');
// Increase the count
count;
}
}
}
.verse { display: none; }
.show { display: block; margin-top: 1em; padding: 0.5em; border: 1px solid #787878; }
[data-id="1"] { background-color: #efefef; }
[data-id="2"] { background-color: #dfdfdf; }
[data-id="3"] { background-color: #cfcfcf; }
[data-id="4"] { background-color: #bfbfbf; }
[data-id="5"] { background-color: #afafaf; }
<div >
<img src="https://dummyimage.com/400x75/404082/ffffff&text=We are still building our website" alt="We are still building on our Website:-)">
</div>
<div>
<div data-id="1" >Lorem Ipsum 1</div>
<div data-id="2" >Lorem Ipsum 2</div>
<div data-id="3" >Lorem Ipsum 3</div>
<div data-id="4" >Lorem Ipsum 4</div>
<div data-id="5" >Lorem Ipsum 5</div>
</div>
Additional documentation
CodePudding user response:
This should make it:
const verses = document.querySelectorAll('.verse');
const banner = document.querySelector('.banner');
const length = verses.length;
let counter = 0;
document.onclick = () => {
if (counter === 0) banner.classList.add('hide');
if (counter >= length) return;
verses[counter].classList.add('show');
if (verses[counter - 1]) verses[counter - 1].classList.remove('show');
counter ;
};
body {
background: orange;
}
.hide {
display: none !important;
}
.show {
display: block !important;
}
<html>
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
<div >
<img
src="files/SomeLogo.jpg"
alt="We are still building on our Website:-)"
/>
</div>
<div id="verses">
<div >Lorem Ipsum</div>
<div >Lorem Ipsum2</div>
<div >Lorem Ipsum3</div>
<div >Lorem Ipsum4</div>
<div >Lorem Ipsum5</div>
</div>
<script src="main.js"></script>
</body>
</html>
CodePudding user response:
Fixing the error
The "Cannot read properties of null" error occurs because you try access the properties of null. Your array holds nulls because you tried to get the elements before the browser has inserted them into its DOM.
The browser parses the HTML the same way you would read it: From left to right, and top to bottom.
If the browser encounters a regular <script>
element, it halts parsing and first executes the JavaScript. Naturally, some elements may not yet be available in the DOM.
There are multiple ways to defer script execution:
- Add attribute
defer
to<script>
: Will execute once the DOM is fully built. - Add attribute
type="module"
to<script>
: Similar todefer
, but will also make your code be treated as a JS module. This will also make your code run in strict mode. - Use JS event
DOMContentLoaded
: Similar todefer
, but encapsulated in your JS-file. - Use JS event
load
: Similar toDOMContentLoaded
, but will additionally wait until all resources (e.g. images, videos) have loaded. PreferDOMContentLoaded
if applicable. - Move
<script>
to the bottom of the HTML: Effectively likedefer
. Scripts withdefer
will still load after scripts at the bottom.
The simplest solution would be to use defer
, as with it you wouldn't have to change your JS code:
<script src="main.js" defer></script>
By the way: Don't be fooled by the StackOverflow snippets; when using the on-site snippets, the <script>
for the code is moved to the bottom of the HTML!
The feature!
Variable lifetimes
Variables in JS only persist for the duration of their context. (Closures will cause their surrounding scope to persist for as long as they live!)
To have a variable persist across calls to a function, it has to be declared outside of this function:
let someVariable = 0;
function aFunc() {
// `someVariable` will persist across calls!
}
This means, the variable to keep track of what verse to show has to be declared outside your function.
Show and hide!
By calculating the previous verse's index with the index of the next-to-show verse, we only have to keep one counter. With two counters, they might get out of sync if we don't handle them correctly.
let nextToShow = 0;
function showNextVerse() {
const previousIndex = nextToShow - 1;
// ...
nextToShow; // Increase counter for next call
}
In our case, the user (or rather, their clicks) will play the role of the loop. They will cause our click handler (the function) to run occasionally, at which point we have to swap the verses.
Swapping the verses can be done in many ways, but we'll stick to your "inline style" way: (Final code)
document.addEventListener("click", showNextVerse);
const banner = document.querySelector(".banner");
const verses = document.getElementById("verses").children; // More on this later
let nextToShow = 0;
function showNextVerse() {
const previousIndex = nextToShow - 1;
// On every call, hide the banner
banner.style.display = "none"; // Use `.style` instead of `.innerHTML` to preserve its HTML!
// Hide previous if valid index
if (previousIndex >= 0 && previousIndex < verses.length) {
verses[previousIndex].style.display = "none";
}
// Show next if valid index
if (nextToShow >= 0 && nextToShow < verses.length) {
verses[nextToShow].style.display = "block";
}
nextToShow;
}
<div >
<script src="main.js" defer></script>
<img alt="We are still building on our Website:-)">
</div>
<div id="verses">
<div style="display:none">Lorem Ipsum1</div>
<div style="display:none">Lorem Ipsum2</div>
<div style="display:none">Lorem Ipsum3</div>
<div style="display:none">Lorem Ipsum4</div>
<div style="display:none">Lorem Ipsum5</div>
</div>
Improvements?!
There is no need for the variable versesLength
; you can directly replace each of its occurences with verses.length
. It doesn't improve on the original name, and is one more potential source for bugs if not synchronized with the original variable.
Correctly use class
and id
Currently, your verses use class
as if it was id
; they each use a different class. This is not wrong, but semantically I would use id
for this purpose.
To use the class
attribute effectively, you should give each verse the class verse
. This way, you can select them more easily via JS (see next section).
Easier getting of elements
As with everything in the coding world, there are many solutions to a problem. You solved getting the elements in a rather tedious way, but there are alternatives: (Non-exhaustive list)
- Use
document.querySelectorAll()
. - Rename verses to use same class, and use
document.getElementsByClassName()
. - Use
Element.children
.
You may have already noticed how I get all the verses. In fact, verses
(in the final code) doesn't even reference an array, but an HTMLCollection
. It is very similar to an array, with the exception of it updating live to changes:
const elementsWrapper = document.getElementById("elements");
const collection = elementsWrapper.children;
const array = Array.from(elementsWrapper.children);
document.getElementById("bt-add").addEventListener("click", function() {
const newElement = document.createElement("div");
newElement.textContent = "Added later";
elementsWrapper.appendChild(newElement);
});
document.getElementById("bt-log").addEventListener("click", function() {
console.log("collection.length:", collection.length);
console.log("array.length:", array.length);
});
<button id="bt-add">Add new element</button>
<button id="bt-log">Log <code>.length</code></button>
<p>
Try logging first! Then try to add elements, and log again! See the difference?
</p>
<div>Elements:</div>
<div id="elements">
<div>Initially existent</div>
<div>Initially existent</div>
</div>
Alternative way of hiding
Here are ways of hiding the elements:
- Use inline styling (this is what you did!).
- Use CSS classes.
- Use the HTML attribute
hidden
.
For small style changes I too would use inline styling. But for only hiding elements I would use the hidden
attribute.
Also, there are multiple CSS ways of hiding elements:
- Using
display: none
: Will hide the element as if it doesn't exist. - Using
opacity: 0
: Will hide the element by making it invisible; it still takes up space, and should still be part of the accessibility tree (opinionated). - Moving it off-site with
position: fixed
andtop
,left
, etc. properties: (Please don't.)
Will move the element off-site, visually. It will still be part of the accessibility tree, and will only work for languages with the intended writing direction (e.g. it won't work for right-to-left languages). - Setting
width
,height
,margin
,padding
andborder
to 0: Will hide the element only visually; it will still be part of the accessibility tree, and will stop margin collapse. Screen-reader only classes use this for non-visual elements, very useful.