#186: Phoenix LiveView Tutorial Part 10

Published January 27, 2024 8m 56s

Currently, our game has a black background and white cell backgrounds, but instead of using a white background for these cells, let’s make them transparent. This will make our user interface here look even better and will make it easier to update the backgrounds of the cells later.

Let’s go to our CellComponent module and add a background assign with an initial value of bg-transparent. This is the Tailwind CSS style with the CSS property background-color of transparent. Then Let’s go to our render callback and pattern match on the assigns to get the background.

Then let’s update the class for the input and add the background assign. Let’s also update the text color to text-slate-200 to give it a better contrast against our background. And while we’re here, let’s also move our id attribute to the wrapping div element. This will make it easier to push updates to our cells later on.

Module path: lib/werdle_web/components/game_board/cell_component.ex

...

def update(...) do
  ...
  |> assign(:background, "bg-transparent")
  ...
end

...

def render(%{id: id, changeset: changeset, background: background} = assigns) do
  ...

  <div id={@id} ...
    <input
      class={"#{@background} text-slate-200 ..."}
      ...
  </div>
  """
end

...

Alright, let’s make sure that our changes work. We’ll go back to the browser and our game looks great with the updated background and text colors for our guesses.

In the last episode we updated our game to use flash messages to display the guess feedback. Now let’s update our game to add more user feedback. To create that feedback let’s use JavaScript Hooks. We’ll create two hooks. Our first one will “shake” a guess row when there’s an incorrect guess. The second one will replace our current guess feedback that uses flash messages. And instead, update it to be dynamically added and removed from the page using JavaScript. For our first hook, we’ll need some CSS for our row “shake” animation. Let’s create a class that we can apply to our row to create the animation.

Let’s open the app.css. I’ll go ahead and paste in the CSS, which is available in the episode notes below, but let’s talk through what’s happening. We have a .shake-element class, which applies an animation named shake. This animation lasts 0.4 seconds and runs once. Then the @keyframes shake rule defines what the shake animation does at various stages from 0% to 100%, moving the element it’s applied to back and forward horizontally 5 pixels.

assets/css/app.css...

.shake-element {
  animation: shake 0.4s 1;
}

@keyframes shake {
  0% { transform: translateX(0); }
  20% { transform: translateX(-5px); }
  35% { transform: translateX(5px); }
  50% { transform: translateX(-5px); }
  65% { transform: translateX(5px); }
  80% { transform: translateX(-5px); }
  100% { transform: translateX(0); }
}

...

Now that we have our CSS let’s create our Hook. If you’re unfamiliar with LiveView JavaScript Hooks, I cover them in episode 114. Let’s create a new directory in “assets/js” called “hooks” - we’ll organize our Hooks in this directory. Then let’s create a new JavaScript file for our Hook, we’ll call it shake_row_animation.js. Let’s export our JavaScript object then we’ll add a mounted life-cycle callback, which is invoked once the LiveView has finished mounting. Now I’ll go ahead and paste in our JavaScript code but let’s talk through what it’s doing.

Our hook here will receive an event from our LiveView so inside the mounted callback we’re using the handleEvent method to handle that event that we’re calling 'shake-row' and it receives a payload that we’re calling data. This will contain the guess row we want to apply the animation. Then we’re creating two constants, inputRowId which is the ID of the row. We’re using the guess row to construct it. We’re using inputRowId to get the element with that ID from the DOM and assign it to the inputRow constant. Then we’re defining an animationEndHandler function. This is called when the animation on the element ends. It removes the shake-element class from the inputRow and then removes the event listener for the animation.

Below we can see we’re adding the shake-element class to the inputRow and then adding an event listener to the inputRow for the animationend event, which will call animationEndHandler once the animation finishes.

assets/js/hooks/shake_row_animation.jsexport default {
  mounted() { 
    this.handleEvent('shake-row', data => {
    const inputRowId = `input-row-${data.row}`;
    const inputRow = document.getElementById(inputRowId);

    const animationEndHandler = () => {
      inputRow.classList.remove('shake-element');
      inputRow.removeEventListener('animationend', animationEndHandler);
    };

    inputRow.classList.add('shake-element');
    inputRow.addEventListener('animationend', animationEndHandler);
  });

  }
};

With our shake_row_animation.js hook created we now need to add it to our app.js so let’s open that. We’ll import ShakeRowAnimation from '.hooks/shake_row_animation.js' then let’s create a Hooks constant and add our ShakeRowAnimation hook to it.

With that, we can update our LiveSocket to include any hooks.

assets/js/app.js...

import ShakeRowAnimation from './hooks/shake_row_animation'

...

const Hooks = {
  ShakeRowAnimation: ShakeRowAnimation
}
let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: Hooks
})

...

With our hook ready to go, we need to add to the DOM. So let’s open up the GridComponent and add the phx-hook attribute with our ShakeRowAnimation hook to the <div>. One note, when using phx-hook a unique DOM ID must always be set. But we already have that here with our id attribute.

Module path: lib/werdle_web/components/game_board/grid_component.ex

...

def render(assigns) do
  ~H"""
  <div id={@id}
           phx-hook="ShakeRowAnimation"

           ...
  """
end
...

Now we need to invoke our hook. Let’s open our WordLive.Index LiveView and in the handle_event for our “enter” event, we have different scenarios for when a player submits a guess. We want to shake animation to fire when the guess is incorrect. So let’s call our hook when it’s an invalid guess, it’s game over, or if the guess is incorrect.

To send an event, we just need to call the push_event function to push an event to the client. We’ll pipe the socket into it and add the "shake-row" event and then for the payload we’ll need to include the “row”, which we’ll get with the current_guess assign.

lib/werdle_web/live/word_live/index.ex...

with {:ok, _changeset} <- Game.validate_guess(changeset, guess_row),
  {:correct, _changeset} <- Game.check_guess_correctness(changeset, guess_row, solve.name) do
  {:noreply, handle_correct_guess(socket, solve)}

else
  {:error, error_message} ->
    {:noreply, handle_invalid_guess(socket, error_message)}

  {:incorrect, _changeset} when guess_row == 5 ->
    {:noreply, handle_game_over(socket, solve)}

  {:incorrect, _changeset} ->
    {:noreply, handle_incorrect_guess(socket, guess_row)}

end

...

defp handle_correct_guess(socket, solve) do
  socket
  |> put_flash(:correct, "#{String.upcase(solve.name)} was the correct word")
end
defp handle_invalid_guess(socket, error_message) do
  socket
  |> push_event("shake-row", %{row: socket.assigns.current_guess})
  |> put_flash(:error, error_message)
end
defp handle_game_over(socket, solve) do
  socket
  |> push_event("shake-row", %{row: socket.assigns.current_guess})
  |> put_flash(:game_over, "The solve was #{String.upcase(solve.name)}")
end
defp handle_incorrect_guess(socket, guess_row) do
  socket
  |> assign(:current_guess, guess_row + 1)
  |> push_event("shake-row", %{row: guess_row})
  |> put_flash(:incorrect, "Try another word")
end

...

Now let’s go to the command line and start our server.

$ mix phx.server
...

And when we submit an incorrect guess. Great, We see that whenever we submit an incorrect guess, the row of the guess shakes!

Now let’s remove the flash message we’re currently using and create another Hook to dynamically add and remove the guess results to the page. We’ll create another javascript file in our “hooks” directory called validation_message_handler.js. Then just like we did for our last hook we’ll export a JavaScript object giving it a mounted callback. We need to add some code that will dynamically update the page with the guess results, which will come from the LiveView. I’ll paste in the rest of the code for our hook, but let’s walk through it. We’re using the handleEvent method to handle another event we’ll receive from our LiveView called “guess-validation-text” and it will receive a payload that we’re calling data. Then we’re getting an element from the DOM that has the ID "game-container". If we don’t have an element with the "game-container" ID or there is not a message key in our data, we’ll return from our hook.

If we have those we’ll create a div element called textBox. Then we’re setting the element’s text content to our message. Then we’re using the classList property with the add function to add some TailwindCSS classes to our new element. We’re adding our new element as a child of the gameContainer.

After that, we’re calling setTimeout to set the opacity to 1 after 100 milliseconds. Then we’re using setTimeout again to call the fadeOutAndRemove function after 5000 milliseconds. Now fadeOutAndRemove is defined below. It takes the element, sets the opacity to 0, and then removes the element from the DOM after 250 milliseconds.

assets/js/hooks/validation_message_handler.jsexport default {
  mounted() {
    this.handleEvent('guess-validation-text', data => {
    const gameContainer = document.getElementById('game-container');
    if (!gameContainer || !data.message) {
      return; // Exit if gameContainer doesn't exist or message is undefined
    }

    const textBox = document.createElement('div');
    textBox.textContent = data.message;
    textBox.classList.add(
      'py-3',
      'px-3',
      'rounded-md',
      'absolute',
      'transition-opacity',
      'duration-500',
      'opacity-0',
      'bg-slate-100',
      'text-gray-900',
      'font-bold',
      'text-sm',
      '-translate-y-7',
      'animate-none'
    );
    gameContainer.appendChild(textBox);

    setTimeout(() => {
      textBox.style.opacity = '1';
    }, 100);

    setTimeout(() => {
      fadeOutAndRemove(textBox);
    }, 5000);
  });

  }
};

function fadeOutAndRemove(element) {
  element.style.opacity = '0';
  setTimeout(() => {
    element.remove();
  }, 250);
}

With that let’s go to the app.js and add import the ValidationMessageHandler from hooks/validation_message_handler. Then we’ll add it to our Hooks below.

assets/app.js...

import ValidationMessageHandler from './hooks/validation_message_handler'
import ShakeRowAnimation from './hooks/shake_row_animation'

...

const Hooks = {
  ValidationMessageHandler: ValidationMessageHandler,
  ShakeRowAnimation: ShakeRowAnimation
}
let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: Hooks
})

...

Now we need to add our ValidationMessageHandler to the DOM. We’ll open our index.html.heex template and we’ll add it to the <div> here, but because we need to use a unique ID with our phx-hook attribute, let’s add the id of game-container here. Then we can add phx-hook="ValidationMessageHandler".

Template path: lib/werdle_web/live/word_live/index.html.heex

<div phx-hook="ValidationMessageHandler" id="game-container" class="flex flex-wrap items-top justify-center w-screen">
...

Great, now let’s go back to our WordLive.Index LiveView and where we’re currently using put_flash to add a flash message, we’ll want to change this to push a “guess-validation-text” event to the client, which includes a payload with the message that we want to display. Let’s update all our functions here to remove the put_flash function and replace it with push_event.

lib/werdle_web/live/word_live/index.ex...

defp handle_correct_guess(socket, solve) do
  message = "#{String.upcase(solve.name)} was the correct word"
  socket
  |> push_event("guess-validation-text", %{message: message})
end
defp handle_invalid_guess(socket, error_message) do
  socket
  |> push_event("shake-row", %{row: socket.assigns.current_guess})
  |> push_event("guess-validation-text", %{message: error_message})
end
defp handle_game_over(socket, solve) do
  message = "The solve was #{String.upcase(solve.name)}"
  socket
  |> push_event("shake-row", %{row: socket.assigns.current_guess})
  |> push_event("guess-validation-text", %{message: message})
end
defp handle_incorrect_guess(socket, guess_row) do
  socket
  |> assign(:current_guess, guess_row + 1)
  |> push_event("shake-row", %{row: guess_row})
  |> push_event("guess-validation-text", %{message: "Try another word"})
end

...

With those updates let’s go back to the browser and now when we submit a guess the result is displayed above our game grid.

Ready to Learn More?

Subscribe to get access to all episodes and exclusive content.

Subscribe Now