Using the keyboard on Murdle cards

Nov 6, 2023Ā·

9 min read

Play this article

Murdle is one of the many Wordle-like daily games. But it's not about words, it's about solving murder mysteries using a logic puzzle. Which are two of my favourite things.

The one thing I don't like about the game is that when I pick a card up, I want to be able to set it down by pressing escape. And go to the next card by pressing the right arrow.

So I thought I wonder if I can do that.

The set up

First I need some cards. I copied them from the site, but I only used their CSS for fonts and colours, and I copied the emoji from their HTML. These don't look identical, but they don't need to, since the important part will be the JavaScript.

Here is the HTML I'm using:

<div class="small-cards">
  <div class="card small-card" data-name="brownstone" role="button" tabindex="0">
    <div class="card--emoji">
      šŸ‘Øā€šŸ¦²
    </div>
    <h2 class="card--name">Brother Brownstone</h2>
  </div>
  <div class="card small-card" data-name="tangerine" role="button" tabindex="0">
    <div class="card--emoji">
      šŸ‘Øā€
    </div>
    <h2 class="card--name">Mx Tangerine</h2>
  </div>
  <div class="card small-card" data-name="saffron" role="button" tabindex="0">
    <div class="card--emoji">
      šŸ‘±ā€ā™€ļø
    </div>
    <h2 class="card--name">Miss Saffron</h2>
  </div>
</div>

<div class="big-cards">
  <div class="big-card card" data-name="brownstone">
    <div class="card--emoji">
      šŸ‘Øā€šŸ¦²
    </div>
    <h2 class="card--name">Brother Brownstone</h2>
    <p class="card--description">A monk who has dedicated his life to the church, specifically to making money for it.</p>
    <p class="card--details">5'4" ā€¢ Left-handed ā€¢ Brown eyes ā€¢ Brown hair ā€¢ Capricorn</p>
    <div class="card--buttons">
      <button class="card--button card--button-setdown">Set card down</button>
      <button class="card--button card--button-arrow" aria-label="Next card">ā®•</button>
     </div>
  </div>

  <div class="big-card card" data-name="tangerine">
    <div class="card--emoji">
      šŸ‘Øā€
    </div>
    <h2 class="card--name">Mx Tangerine</h2>
    <p class="card--description">Proving that non-binary people can be murderers, too, Mx. Tangerine is an artist, poet, and potential suspect.</p>
    <p class="card--details">5'5" ā€¢ Left-handed ā€¢ Hazel eyes ā€¢ Blond hair ā€¢ Pisces</p>
    <div class="card--buttons">
      <button class="card--button card--button-setdown">Set card down</button>
      <button class="card--button card--button-arrow" aria-label="Next card">ā®•</button>
     </div>
  </div>

  <div class="big-card card" data-name="saffron">
    <div class="card--emoji">
      šŸ‘±ā€ā™€ļø
    </div>
    <h2 class="card--name">Miss Saffron</h2>
    <p class="card--description">Gorgeous and stunning, but maybe not all there in the brains department. Or maybe that's what she wants you to think. Or maybe she wants you to think that's what she wants you to think. Her pet poodle accompanies her everywhere she goes.</p>
    <p class="card--details">5'2" ā€¢ Left-handed ā€¢ Hazel eyes ā€¢ Blond hair ā€¢ Libra</p>
    <div class="card--buttons">
      <button class="card--button card--button-setdown" tabindex="0">Set card down</button>
      <button class="card--button card--button-arrow" aria-label="Next card">ā®•</button>
     </div>
  </div>
</div>

There are three small cards that will be next to each other. Then three corresponding big cards, which will be shown when the relevant small card is clicked.

I'm using a data attribute to name the cards based on the suspect's name. This is because the colours are based on the suspect (all suspects have colour-based names). And also when we click the small card we can use that to work out which big card to show.

Then there's the CSS:

:root {
  --card-color: #000000;
}

html,
body {
  height: 100%;
}

.small-cards {
  align-items: center;
  display: flex;
  height: 100%;
  justify-content: center;
  gap: 10px;
}

.card {
  align-items: center;
  background-color: #fffdf5;
  border: 5px solid #000000;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
  gap: 0.625rem;
  justify-content: center;
  height: 200px;
  padding: 0.25em;
  width: 150px;
}

.card--emoji {
  color: transparent;
  font-size: 350%;
  text-align: center;
  text-shadow: 0 0 0 var(--card-colour);
}

.card--name {
  color: var(--card-colour);
  font-family: courier;
  font-size: 1.2rem;
  font-weight: 700;
  text-align: center;
  text-transform: uppercase;
}

.card--description {
  font-family: courier;
  font-size: 1.2rem;
  line-height: 1.7;
}

.card--details {
  font-family: courier;
  font-size: 1.2rem;
  font-weight: 700;
  line-height: 1.7;
  text-align: center;
  text-transform: uppercase;
}

.card--buttons {
  align-items: stretch;
  display: flex;
  gap: 0.5em;
  margin-top: auto;
  width: 100%;
}

.card--button {
  background-color: #000000;
  border: none;
  box-shadow: 4px 4px #1dacd6;
  color: #ffffff;
  cursor: pointer;
  font-family: courier;
  font-size: 1.2rem;
  font-weight: 700;
  padding-block: 1.5em;
  padding-inline: 2em;
  text-transform: uppercase;
}

.card--button:focus-visible {
  outline: 3px solid #1dacd6;
}

.card--button-arrow {
  align-items: center;
  display: flex;
  font-size: 1rem;
}

.card--button-setdown {
  flex-shrink: 0;
  flex-grow: 1;
}

.big-cards {
  display: none;
  background: white;
  height: 90%;
  left: 0;
  margin: auto;
  position: absolute;
  top: 0;
  width: 90%;
}

.big-card {
  cursor: auto;
  display: none;
  flex-direction: column;
  left: 50%;
  height: fit-content;
  min-height: 550px;
  padding-bottom: 1.25em;
  padding-inline: 0.625em;
  padding-top: 1.75em;
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 370px;
}

.big-card .card--name {
  font-size: 1.75rem;
}

.big-cards.show {
  display: block;
}

.big-card.show {
  display: flex;
}

.card[data-name="brownstone"] {
  --card-colour:#834333;
}

.card[data-name="tangerine"] {
  --card-colour: #f28500;
}

.card[data-name="saffron"] {
  --card-colour: #f4c430;
}

The easiest way to do the emoji and name colours is with a custom property. Everything is centred on screen and the big cards are hidden for now. Including their container, which will cover up everything else on screen when they are showing.

Hiding and showing cards on click

I'm starting simple. If you click on a card it'll show the corresponding big card. And if you click on the 'Set card down' button on the big card it'll hide it again.

const smallCards = document.querySelectorAll('.small-card');
const bigCards = document.querySelectorAll('.big-card');
const bigCardsContainer = document.querySelector('.big-cards');

const setCardDown = (card) => {
  card.classList.remove('show');
  bigCardsContainer.classList.remove('show');  
}

const closeBigCard = (event) => {
  const card = event.currentTarget;
  const whatWasClicked = event.target;
  // Close the card
  if (event.target.classList.contains('card--button-setdown')) {
    setCardDown(card);
  }
}

const openBigCard = (event) => {   
  bigCardsContainer.classList.add('show');
  const cardName = event.currentTarget.dataset.name;  
  bigCards.forEach(bigCard => {
    if (bigCard.dataset.name === cardName) {
      bigCard.classList.add('show');
      bigCard.addEventListener('click', closeBigCard);
    }
  })
}

smallCards.forEach(card => {
  card.addEventListener('click', openBigCard);
})

In here, we're listening for a click on the small card. When we click, it will show the big card container. Then work out which card to show. Now it's showing, we'll add an event listener for clicks on the big card.

When you click on the big card we'll check where you clicked, since it could be on either button. If it's the set down button, then all we need to do is to hide both the big card and the big card container.

So far it's simple, but now let's add the keyboard listeners.

Hiding and showing cards when a key was pressed

const smallCards = document.querySelectorAll('.small-card');
const bigCards = document.querySelectorAll('.big-card');
const bigCardsContainer = document.querySelector('.big-cards');

const setCardDown = (card) => {
  card.classList.remove('show');
  bigCardsContainer.classList.remove('show');  
}

const closeBigCard = (event) => {
  const card = event.currentTarget;
  const whatWasClicked = event.target;
  // Close the card
  if (event.target.classList.contains('card--button-setdown')) {
    setCardDown(card);
  }
}

const openBigCard = (event) => {   
  bigCardsContainer.classList.add('show');
  const cardName = event.currentTarget.dataset.name;  
  bigCards.forEach(bigCard => {
    if (bigCard.dataset.name === cardName) {
      bigCard.classList.add('show');
      bigCard.addEventListener('click', closeBigCard);
      document.addEventListener('keyup', function keyup(e) {
        if (e.key === 'Escape') {
          event.preventDefault();
          setCardDown(bigCard);
        }
        document.removeEventListener('keyup', keyup);
      })
    }
  })
}

smallCards.forEach(card => {
  card.addEventListener('click', openBigCard);
  card.addEventListener('keyup', function() {
    if (event.key === 'Enter' || event.key === ' ') { 
      event.preventDefault();
      openBigCard(event);
    }
  });
})

The good news here is that because the set down button on the big card is a button, the click event listener also works if you press Enter or the space bar. However, the card only has the role of button, which tells screen readers you can press Enter or space bar on it and it will do something. But the click event doesn't know that. So we listen for a key being pressed and if it's the relevant one, we can run the function to show the big card.

Pressing escape to close it is more complicated because you could be focused anywhere. That means that listener needs to listen to the document. And once it establishes the Escape key has been pressed, we need to remove the listener because otherwise it'll keep registering that Escape has been pressed, even though the big card is not open.

Show next card

const smallCards = document.querySelectorAll('.small-card');
const bigCards = document.querySelectorAll('.big-card');
const bigCardsContainer = document.querySelector('.big-cards');

const setCardDown = (card) => {
  card.classList.remove('show');
  bigCardsContainer.classList.remove('show');  
}

const closeBigCard = (event) => {
  const card = event.currentTarget;
  const whatWasClicked = event.target;
  // Close the card
  if (event.target.classList.contains('card--button-setdown')) {
    setCardDown(card);
  }
  // Show the next card
  // unless this is the first card, in which case show the first card
  if (event.target.classList.contains('card--button-arrow')) {
    showNextCard(card);
  }
}

const showNextCard = (card) => {
  // Find which card this is
  const cardIndex = Array.from(bigCards).indexOf(card);
  card.classList.remove('show');
  let cardShowing;
  if (cardIndex < bigCards.length - 1) {
    bigCards[cardIndex + 1].classList.add('show');
    cardShowing = bigCards[cardIndex + 1];
  } else {
    bigCards[0].classList.add('show');
    cardShowing = bigCards[0];
  }
  cardShowing.addEventListener('click', closeBigCard);
  document.addEventListener('keyup', function keyup(e) {
    if (e.key === 'Escape') {
      event.preventDefault();
      setCardDown(cardShowing);
    }
    if (e.key === 'ArrowRight') {
      event.preventDefault();
      showNextCard(cardShowing);
    }
    document.removeEventListener('keyup', keyup);
  })
}

const openBigCard = (event) => {   
  bigCardsContainer.classList.add('show');
  const cardName = event.currentTarget.dataset.name;  
  bigCards.forEach(bigCard => {
    if (bigCard.dataset.name === cardName) {
      bigCard.classList.add('show');
      bigCard.addEventListener('click', closeBigCard);
      document.addEventListener('keyup', function keyup(e) {
        if (e.key === 'Escape') {
          event.preventDefault();
          setCardDown(bigCard);
        }
        if (e.key === 'ArrowRight') {
          event.preventDefault();
          showNextCard(bigCard);
        }
        document.removeEventListener('keyup', keyup);
      })
    }
  })
}

smallCards.forEach(card => {
  card.addEventListener('click', openBigCard);
  card.addEventListener('keyup', function() {
    if (event.key === 'Enter' || event.key === ' ') { 
      event.preventDefault();
      openBigCard(event);
    }
  });
})

Again, our keyboard listener needs to be on the document, because you could be anywhere when pressing the right arrow. But that's fine because it will all work the same way as pressing escape. And the click event works for pressing Enter or space bar when focused on the arrow button.

The complicated bit here is working out which card to show next. Handily, we have them all in a Node list, but that can easily be turned into an array with Array.from. Then we can just find the index of this one and show the index + 1 card (and hide the index card). Unless this is the last card, in which case we want to show the first one.

This always gets a bit confusing because the last card has an index of 2, but there are 3 cards in the array. So we need to compare the index vs the array length - 1. But once you get your head around that, it's fine.

After that's all sorted out, we have the event listeners for closing the big card once again. This is so we just have the event listener on the big card that's showing. It doesn't matter if we close all the big cards, since only one is ever showing at once. But cycling through all the cards when trying to get to the next one is not helpful.

I could probably put the repeated event listeners in a function. And definitely would if I added in something to go to the previous card. But this all took me a while, so I decided once it was working I wouldn't fix it.

Things I didn't do

Ideally when the big card opened, the set card down button would be focused. And that focus would be trapped, so when you press tab it would cycle between the two buttons. But that's beyond the scope of what I was trying to do, so I didn't include it.

Ideally all the big cards would be the same size and fit into the space, no matter how short it is (well, to a point). But this was about the function rather than the display.

Final code

The final code can be seen in this code pen: https://codepen.io/nicm42/full/WNYqEeX. It's set to view just the results, because it doesn't necessarily fit in if you have your code showing too. But clicking the 'View Source Code' button will show you it.

Ā