Skip to content

Instantly share code, notes, and snippets.

@cferdinandi
Created July 31, 2024 16:46
Show Gist options
  • Save cferdinandi/3dc2826f88dd5195a113d79e399ffc91 to your computer and use it in GitHub Desktop.
Save cferdinandi/3dc2826f88dd5195a113d79e399ffc91 to your computer and use it in GitHub Desktop.
Can I add state-based UI to a Web Component? Watch at https://youtu.be/yBENeFs_dKs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tick Tac Toe | React</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: sans-serif;
margin: 1em auto;
max-width: 40em;
width: 88%;
}
* {
box-sizing: border-box;
}
h1 {
margin-top: 0;
font-size: 22px;
}
h2 {
margin-top: 0;
font-size: 20px;
}
h3 {
margin-top: 0;
font-size: 18px;
}
h4 {
margin-top: 0;
font-size: 16px;
}
h5 {
margin-top: 0;
font-size: 14px;
}
h6 {
margin-top: 0;
font-size: 12px;
}
code {
font-size: 1.2em;
}
ul {
padding-inline-start: 20px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.board-row:after {
clear: both;
content: '';
display: table;
}
.status {
margin-bottom: 10px;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
</style>
</head>
<body>
<h1>Tic Tac Toe - React</h1>
<div id="root"></div>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script async src="https://ga.jspm.io/npm:[email protected]/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react?dev",
"react-dom/client": "https://esm.sh/react-dom/client?dev"
}
}
</script>
<script type="text/babel" data-type="module">
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
if (calculateWinner(squares) || squares[i]) {
return;
}
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = 'X';
} else {
nextSquares[i] = 'O';
}
onPlay(nextSquares);
}
const winner = calculateWinner(squares);
let status;
if (winner) {
status = 'Winner: ' + winner;
} else {
status = 'Next player: ' + (xIsNext ? 'X' : 'O');
}
return (
<>
<div className="status">{status}</div>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
<Square value={squares[1]} onSquareClick={() => handleClick(1)} />
<Square value={squares[2]} onSquareClick={() => handleClick(2)} />
</div>
<div className="board-row">
<Square value={squares[3]} onSquareClick={() => handleClick(3)} />
<Square value={squares[4]} onSquareClick={() => handleClick(4)} />
<Square value={squares[5]} onSquareClick={() => handleClick(5)} />
</div>
<div className="board-row">
<Square value={squares[6]} onSquareClick={() => handleClick(6)} />
<Square value={squares[7]} onSquareClick={() => handleClick(7)} />
<Square value={squares[8]} onSquareClick={() => handleClick(8)} />
</div>
</>
);
}
let App = function Game() {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
const xIsNext = currentMove % 2 === 0;
const currentSquares = history[currentMove];
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
}
function jumpTo(nextMove) {
setCurrentMove(nextMove);
}
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = 'Go to move #' + move;
} else {
description = 'Go to game start';
}
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
<ol>{moves}</ol>
</div>
</div>
);
}
function calculateWinner(squares) {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
for (let i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i];
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tick Tac Toe | Web Component</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: sans-serif;
margin: 1em auto;
max-width: 40em;
width: 88%;
}
* {
box-sizing: border-box;
}
h1 {
margin-top: 0;
font-size: 22px;
}
h2 {
margin-top: 0;
font-size: 20px;
}
h3 {
margin-top: 0;
font-size: 18px;
}
h4 {
margin-top: 0;
font-size: 16px;
}
h5 {
margin-top: 0;
font-size: 14px;
}
h6 {
margin-top: 0;
font-size: 12px;
}
code {
font-size: 1.2em;
}
ul {
padding-inline-start: 20px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.board-row:after {
clear: both;
content: '';
display: table;
}
.status {
margin-bottom: 10px;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
</style>
</head>
<body>
<h1>Tic Tac Toe - Web Component</h1>
<tic-tac-toe></tic-tac-toe>
<script>
customElements.define('tic-tac-toe', class extends HTMLElement {
/**
* Instantiate our Web Component
*/
constructor () {
// Inherit the parent class properties
super();
// Define properties
this.nextMove = 'X';
this.winner = null;
this.history = [];
this.jumpTo = null;
// Render the HTML into the DOM
this.innerHTML =
`<div class="game">
<div class="game-board">
<div class="status"></div>
<div class="board-row">
<button class="square"></button>
<button class="square"></button>
<button class="square"></button>
</div>
<div class="board-row">
<button class="square"></button>
<button class="square"></button>
<button class="square"></button>
</div>
<div class="board-row">
<button class="square"></button>
<button class="square"></button>
<button class="square"></button>
</div>
</div>
<div class="game-info">
<ol>
<li><button jump-to="0">Go to game start</button></li>
</ol>
</div>
</div>`;
// Get HTML elements
this.gameStatus = this.querySelector('.status');
this.gameInfo = this.querySelector('.game-info ol');
this.squares = Array.from(this.querySelectorAll('.square'));
// Set default status
this.updateStatus();
// Set default history
this.updateHistory();
// Listen for click events
this.addEventListener('click', this);
}
/**
* Handle events
* @param {Event} event The event object
*/
handleEvent (event) {
this.handleSquares(event);
this.handleJumpTo(event);
}
/**
* Handle click events on game squares
* @param {Event} event The event object
*/
handleSquares (event) {
// Only run for .square clicks
let btn = event.target.closest('.square');
if (!btn || btn.hasAttribute('is-filled') || this.winner) return;
// Update the text of the button
btn.textContent = this.nextMove;
// Disable the button from future clicks
btn.setAttribute('is-filled', this.nextMove);
// Determine if there's a winner
this.winner = this.calculateWinner();
// Update the next move
this.nextMove = this.nextMove === 'X' ? 'O' : 'X';
this.updateStatus();
// Update history
this.updateGameInfo();
this.jumpTo = null;
}
/**
* Handle click events on the jump to links
* @param {Event} event The event object
*/
handleJumpTo (event) {
// Only run for .square clicks
let btn = event.target.closest('[jump-to]');
if (!btn) return;
// Get the jump to index
this.jumpTo = parseFloat(btn.getAttribute('jump-to'));
// Get the corresponding history item
let history = this.history[this.jumpTo];
// Set board to historical state
this.squares.forEach((square, index) => {
square.textContent = history.boardState[index] || '';
if (history.boardState[index]) {
square.setAttribute('is-filled', history.boardState[index]);
} else {
square.removeAttribute('is-filled');
}
});
// Reset the next move and winner
this.nextMove = history.nextMove;
this.winner = history.winner;
this.updateStatus();
}
/**
* Update the game status
*/
updateStatus () {
// if there's a winner, announce them
if (this.winner) {
this.gameStatus.textContent = `Winner: ${this.winner}`;
return;
}
// Otherwise, show next move
this.gameStatus.textContent = `Next Player: ${this.nextMove}`;
}
/**
* Update the game info section
*/
updateGameInfo () {
// Update game history
this.updateHistory();
// If we're on a historical state, wipe all that come after it
if (this.jumpTo !== null) {
let items = this.gameInfo.querySelectorAll('li');
items.forEach((item, index) => {
if (index > this.jumpTo) {
item.remove();
}
});
}
// Add button to game info
let li = document.createElement('li');
li.innerHTML = `<button jump-to="${this.history.length - 1}">Go to move #${this.history.length}</button>`;
this.gameInfo.append(li);
}
/**
* Update game history
*/
updateHistory () {
// If currently on a historical entry, wipe all that come after it
if (this.jumpTo !== null) {
this.history = this.history.slice(0, this.jumpTo + 1);
}
// Add new history entry
this.history.push({
boardState: this.getSquareValues(),
nextMove: this.nextMove,
winner: this.winner
});
}
/**
* Calculate if there's a winner
* @return {String} The winning value, if one exists
*/
calculateWinner () {
// Winning combinations
let lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
// Get the current values
let squares = this.getSquareValues();
// Find the matching value (if one exists)
for (let i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i];
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
// Otherwise, return null
return null;
}
/**
* Get the values of the game square
* @return {Array} The game square values
*/
getSquareValues () {
return this.squares.map((square) => {
return square.getAttribute('is-filled');
});
}
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tick Tac Toe | State-Based UI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: sans-serif;
margin: 1em auto;
max-width: 40em;
width: 88%;
}
* {
box-sizing: border-box;
}
h1 {
margin-top: 0;
font-size: 22px;
}
h2 {
margin-top: 0;
font-size: 20px;
}
h3 {
margin-top: 0;
font-size: 18px;
}
h4 {
margin-top: 0;
font-size: 16px;
}
h5 {
margin-top: 0;
font-size: 14px;
}
h6 {
margin-top: 0;
font-size: 12px;
}
code {
font-size: 1.2em;
}
ul {
padding-inline-start: 20px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.board-row:after {
clear: both;
content: '';
display: table;
}
.status {
margin-bottom: 10px;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
</style>
</head>
<body>
<h1>Tic Tac Toe - Web Component - State-Based UI</h1>
<tic-tac-toe></tic-tac-toe>
<script src="https://cdn.jsdelivr.net/npm/reefjs@13/dist/reef.min.js"></script>
<script>
let {signal, component} = reef;
customElements.define('tic-tac-toe', class extends HTMLElement {
/**
* Instantiate our Web Component
*/
constructor () {
// Inherit the parent class properties
super();
// Define properties
this.uuid = crypto.randomUUID();
this.nextMove = 'X';
this.winner = null;
this.squares = signal(Array(9).fill(null), this.uuid);
this.history = [];
this.jumpTo = null;
// Set default history
this.updateHistory();
// Render the HTML into the DOM
component(this, this.template, {signals: [this.uuid]});
// Listen for click events
this.addEventListener('click', this);
}
/**
* The UI template
* @return {Function} A function that returns the HTML string
*/
template = () => {
return `<div class="game">
<div class="game-board">
<div class="status">${this.winner ? `Winner: ${this.winner}` : `Next Player: ${this.nextMove}`}</div>
<div class="board-row">
<button class="square" key="0">${this.squares[0] || ''}</button>
<button class="square" key="1">${this.squares[1] || ''}</button>
<button class="square" key="2">${this.squares[2] || ''}</button>
</div>
<div class="board-row">
<button class="square" key="3">${this.squares[3] || ''}</button>
<button class="square" key="4">${this.squares[4] || ''}</button>
<button class="square" key="5">${this.squares[5] || ''}</button>
</div>
<div class="board-row">
<button class="square" key="6">${this.squares[6] || ''}</button>
<button class="square" key="7">${this.squares[7] || ''}</button>
<button class="square" key="8">${this.squares[8] || ''}</button>
</div>
</div>
<div class="game-info">
<ol>
${this.history.map((entry, index) => {
return `<li><button jump-to="${index}">Go to ${index === 0 ? 'game start' : `move #${index}`}</button></li>`;
}).join('')}
</ol>
</div>
</div>`;
}
/**
* Handle events
* @param {Event} event The event object
*/
handleEvent (event) {
this.handleSquares(event);
this.handleJumpTo(event);
}
/**
* Handle click events on game squares
* @param {Event} event The event object
*/
handleSquares (event) {
// Only run for .square clicks
let btn = event.target.closest('.square');
if (!btn || this.winner) return;
// Get the button key
let key = btn.getAttribute('key');
if (!key || this.squares[key] !== null) return;
// Update the text of the button
this.squares[key] = this.nextMove;
// Update the next move
this.nextMove = this.nextMove === 'X' ? 'O' : 'X';
// Determine if there's a winner
this.winner = this.calculateWinner();
// Update history
this.updateHistory();
this.jumpTo = null;
}
/**
* Handle click events on the jump to links
* @param {Event} event The event object
*/
handleJumpTo (event) {
// Only run for .square clicks
let btn = event.target.closest('[jump-to]');
if (!btn) return;
// Get the jump to index
this.jumpTo = parseFloat(btn.getAttribute('jump-to'));
// Get the corresponding history item
let history = this.history[this.jumpTo];
// Set board to historical state
history.boardState.forEach((square, index) => {
this.squares[index] = square;
});
// Reset the next move and winner
this.nextMove = history.nextMove;
this.winner = history.winner;
}
/**
* Update game history
*/
updateHistory () {
// If currently on a historical entry, wipe all that come after it
if (this.jumpTo !== null) {
this.history = this.history.slice(0, this.jumpTo + 1);
return;
}
// Add new history entry
this.history.push({
boardState: Array.from(this.squares),
nextMove: this.nextMove,
winner: this.winner
});
}
/**
* Calculate if there's a winner
* @return {String} The winning value, if one exists
*/
calculateWinner () {
// Winning combinations
let lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
// Get the current values
let squares = this.squares;
// Find the matching value (if one exists)
for (let i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i];
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
// Otherwise, return null
return null;
}
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment