Saturday 2 November 2024

Halloween 2024

I spent a happy few hours working on these challenges, these are my solutions:

function createMagicPotion(potions, target) {
  const c = potions.reduce((acc, _, index) => {
    for (let i = index + 1; i < potions.length; i++) {
      if (potions[index] + potions[i] === target) {
        acc.push([index, i]);
      }
    }
    return acc;
  }, []);
  return c[0].length === 1
    ? c[0]
    : c[0].length > 1
      ? c.sort((a, b) => a[1] - b[1])[0]
      : undefined;
}
console.log(createMagicPotion([4, 5, 6, 2], 8));
console.log(createMagicPotion([1, 2, 3, 4], 9));
console.log(createMagicPotion([1, 2, 3, 4], 5));

function battleHorde(zombies, humans) {
  const iteration = Math.max(zombies.length, humans.length);
  const z = zombies.split("").map((e) => Number(e));
  const h = humans.split("").map((e) => Number(e));
  for (let i = 0; i < iteration; i++) {
    if (i + 1 < iteration) {
      console.log(`zombie ${z[i]} vs human ${h[i]}`);
      console.log(`start: humans ${h.join("")}, zombies ${z.join("")}`);
      if (z[i] === h[i]) {
        console.log("tie");
      } else {
        if (z[i] > h[i]) {
          console.log(`zombie wins (${z[i] - h[i]})`);
          z[i + 1] = z[i + 1] + (z[i] - h[i]);
        } else {
          console.log(`human wins (${h[i] - z[i]})`);
          h[i + 1] = h[i + 1] + (h[i] - z[i]);
        }
      }
      console.log(`end: humans ${h.join("")}, zombies ${z.join("")}`);
    } else {
      if (z[i] > h[i]) {
        return `${z[i] - h[i]}z`;
      } else if (z[i] < h[i]) {
        return `${h[i] - z[i]}h`;
      } else {
        return "x";
      }
    }
  }
}
console.log(battleHorde("242", "334"));
console.log(battleHorde("444", "282"));

function findSafestPath(dream) {
  function findPaths(arr, path, i, j, paths) {
    if (i === M - 1 && j === N - 1) {
      path.push(arr[i][j]);
      paths.push(path.reduce((a, c) => a + c, 0));
      path.pop();
      return;
    }
    if (i < 0 || i >= M || j < 0 || j >= N) {
      return;
    }
    path.push(arr[i][j]);
    if (j + 1 < N) {
      findPaths(arr, path, i, j + 1, paths);
    }
    if (i + 1 < M) {
      findPaths(arr, path, i + 1, j, paths);
    }
    path.pop();
  }
  const arr = JSON.parse(JSON.stringify(dream));
  const path = [];
  const paths = [];
  let i = 0,
    j = 0;
  const M = arr.length;
  const N = arr[0].length;
  findPaths(arr, path, i, j, paths);
  return Math.min(...paths);
}
console.log(
  findSafestPath([
    [1, 3, 1],
    [1, 5, 1],
    [4, 2, 1],
  ]),
);

function findTheKiller(whisper, suspects) {
  const regex = new RegExp(
    whisper.substring(whisper.length - 1) === "$"
      ? whisper.replaceAll("~", ".")
      : `${whisper.replaceAll("~", ".")}.*`,
    "i",
  );
  return suspects
    .filter((suspect) => regex.exec(suspect)?.[0] === suspect)
    .join(",");
}
console.log(
  findTheKiller("d~~~~~a", [
    "Dracula",
    "Freddy Krueger",
    "Jason Voorhees",
    "Michael Myers",
  ]),
);
console.log(findTheKiller("~r~dd~", ["Freddy", "Freddier", "Fredderic"]));
console.log(findTheKiller("~r~dd$", ["Freddy", "Freddier", "Fredderic"]));
console.log(findTheKiller("mi~~def", ["Midudev", "Midu", "Madeval"]));

function escapePyramidHead(room) {
  const rows = room.length;
  const cols = room[0].length;

  // Find start (▲) and end (T) positions
  let start, end;
  for (let y = 0; y < rows; y++) {
    for (let x = 0; x < cols; x++) {
      if (room[y][x] === "") {
        start = [x, y];
      } else if (room[y][x] === "T") {
        end = [x, y];
      }
    }
  }

  // If either start or end not found
  if (!start || !end) {
    return -1;
  }

  // Possible moves: up, right, down, left
  const directions = [
    [-1, 0], // left
    [0, 1], // down
    [1, 0], // right
    [0, -1], // up
  ];

  // Helper function to check if a point is valid
  function isValidPoint(row, col) {
    return row >= 0 && row < rows && col >= 0 && col < cols;
  }

  // Initialize visited array and queue for BFS
  const visited = Array(rows)
    .fill()
    .map(() => Array(cols).fill(false));
  const queue = [];

  // Start BFS
  queue.push({ y: start[1], x: start[0], distance: 0 });
  visited[start[1]][start[0]] = true;

  while (queue.length > 0) {
    const current = queue.shift();

    // Check if we reached the end point
    if (current.y === end[1] && current.x === end[0]) {
      return current.distance;
    }

    // Try all possible directions
    for (const [dx, dy] of directions) {
      const newY = current.y + dy;
      const newX = current.x + dx;

      // Check if the new position is valid and not visited
      if (
        isValidPoint(newY, newX) &&
        !visited[newY][newX] &&
        room[newY][newX] !== "#"
      ) {
        visited[newY][newX] = true;
        queue.push({
          y: newY,
          x: newX,
          distance: current.distance + 1,
        });
      }
    }
  }

  // If we get here, no path was found
  return -1;
}

console.log(
  escapePyramidHead([
    [".", ".", "#", ".", ""],
    ["#", ".", "#", ".", "#"],
    [".", ".", ".", ".", "."],
    ["#", "#", "#", ".", "#"],
    ["T", ".", ".", ".", "."],
  ]),
);
console.log(
  escapePyramidHead([
    ["", ".", "#", "."],
    [".", ".", ".", "."],
    ["T", ".", ".", "#"],
    [".", "#", "#", "#"],
  ]),
);
console.log(
  escapePyramidHead([
    ["#", "#", "#"],
    ["", ".", "#"],
    [".", "#", "T"],
  ]),
);

Saturday 26 October 2024

React and Markdown: Better to intercept or replace anchor elements in markdown?

We're setting up an Archery Wiki on our site and using Firebase as our back end. Adding wikis is simple enough, as we can use a simple markdown editor, and displaying them is easy enough using the marvellous react-markdown package. However, navigation triggered a whole page refresh, which was less than ideal.

I thought there must be a way of intercepting anchor element clicks in React, but after searching for a while, I realised that intercepting clicks on all links was less than ideal anyway. Wouldn't it be better to listen to internal links, replace them with pseudo anchor elements, and trigger React's navigation instead?

So that's what I did!

This is the relevant component in context:

<ReactMarkdown
  components={{
    a: (props) => {
      return props.href.startsWith(
        "https://witchfordarchers.club/wiki/",
      ) ? (
        <span
          style={{
            cursor: "pointer",
            textDecoration: "underline",
          }}
          title={props.href.replace(
            "https://witchfordarchers.club/wiki/",
            "",
          )}
          onClick={handleNavigation.bind(
            this,
            props.href.replace("https://witchfordarchers.club/wiki/", ""),
          )}
        >
          {props.children}
        </span>
      ) : (
        <a href={props.href}>{props.children}</a> // All other links
      );
    },
  }}
>
  {wiki.content}
</ReactMarkdown>

Of course, it requires a handleNavigation handler being passed down, but that's dead simple to add to the calling page.

This is an example of the code working, and this is the implementation on Witchford Archers.

Saturday 19 October 2024

HEDGE CLOCK

Ages ago I wrote a simple clock, and recently I came across some lovely images by TransientCode, the combination of these factors led me to create a simple clock using the images; this is the result:

If you follow the source then you might notice that it makes noises each time the numerals change - each numeral has it's own sound - but this can become annoying! It won't make any sound until you click on the page though.

Saturday 31 August 2024

AskJS Toy problem

There was a post on Reddit last week that got me sort of excited (I probably need to get out more).

It's been removed now, but my RSS feed still has it:

Write a function that takes a string representing open hours and returns an object where the keys are days of the week and the values are the corresponding open hours. Missing days should still be represented as a key but with an empty string value.

Examples:

"Mon-Sun 11:00 am - 10 pm" would return:

{
  Mon: "11:00 am - 10:00 pm", 
  Tues: "11:00 am - 10:00 pm", 
  Wed: "11:00 am - 10:00 pm", 
  Thu: "11:00 am - 10:00 pm", 
  Fri: "11:00 am - 10:00 pm", 
  Sat: "11:00 am - 10:00 pm", 
  Sun: "11:00 am - 10:00 pm"
}

"Mon-Thu, Sat 11 am - 9 pm / Fri 5 pm - 9 pm" would return:

{
  Mon: "11:00 am - 9:00 pm",
  Tues: "11:00 am - 9:00 pm",
  Wed: "11:00 am - 9:00 pm",
  Thu: "11:00 am - 9:00 pm",
  Fri: "5:00 pm - 9:00 pm",
  Sat: 11:00 am - 9:00 pm",
  Sun: ""
}

Additional test cases:

  • "Mon-Sun 11 am - 12 am"
  • "Mon-Fri, Sat 11 am - 12 pm / Sun 11 am - 10 pm"
  • "Mon-Thu, Sun 11:30 am - 10 pm / Fri-Sat 11:30 am - 11 pm"
  • "Mon-Thu 11 am - 11 pm / Fri-Sat 11 am - 12:30 am / Sun 10 am - 11 pm"

This is what I came up with:

/*
 * Names regex groups are really handy! Remember to end a regex with a ";"!
 */
const timeRegex = /(?<sHour>\d{1,2})(\:(?<sMinute>\d{1,2}))?\s(?<sAmPm>am|pm)\s\-\s(?<eHour>\d{1,2})(\:(?<eMinute>\d{1,2}))?\s(?<eAmPm>am|pm)/;
const daysRegex = /\b([a-zA-Z]{3,4}\-?)\b/g;
/*
 * Our tests:
 */
const tests = [
  "Mon-Sun 11:00 am - 10 pm",
  "Mon-Sun 11 am - 12 am",
  "Mon-Thu, Sat 11 am - 9 pm / Fri 5 pm - 9 pm",
  "Mon-Fri, Sat 11 am - 12 pm / Sun 11 am - 10 pm",
  "Mon-Thu, Sun 11:30 am - 10 pm / Fri-Sat 11:30 am - 11 pm",
  "Mon-Thu 11 am - 11 pm / Fri-Sat 11 am - 12:30 am / Sun 10 am - 11 pm"
]
/*
 * Our original object
 */
const daysObj = { "Mon": "", "Tues": "", "Wed": "", "Thu": "", "Fri": "", "Sat": "", "Sun": "" }
/*
 * Our array of days from the object above
 */
const days = Object.keys(daysObj)
/*
 * Our function to format a time
 */
const formatTime = (hour, minute, AmPm) => `${hour}:${minute} ${AmPm}`
/*
 * Our function to format a time duration
 */
const generateTime = (sHour, sMinute, sAmPm, eHour, eMinute, eAmPm) => {
  const sTime = formatTime(sHour, sMinute, sAmPm)
  const eTime = formatTime(eHour, eMinute, eAmPm)
  return `${sTime} - ${eTime}`
}
/*
 * Iterate over the tests
 */
for (const test of tests) {
  /*
   * Start with a fresh version of the days object by copying the one from above.
   */
  const returnObj = Object.assign({}, daysObj)
  /*
   * First we need to chunks of the string seperated by " / "
   * We'll then iterate over each chunk.
   */
  for (chunk of test.split(" / ")) {
    /*
     * We'll then get all the days from the chunk.
     */
    const daysResult = chunk.match(daysRegex)
    /*
     * We'll then destructure the groups from the regex match. Named regex groups are really helpful for this as we
     * we can then access the values by name from the groups object, and have default values for those that might be 
     * undefined.
     */
    const { sHour, sMinute = '00', sAmPm, eHour, eMinute = '00', eAmPm } = timeRegex.exec(chunk.trim()).groups
    /*
     * We'll then generate the time from the values we've extracted from the regex match.
     */
    const timeString = generateTime(sHour, sMinute, sAmPm, eHour, eMinute, eAmPm)
    /*
     * We'll then iterate over the days we've extracted from the chunk.
     */
    for (let i = 0; i < daysResult.length; i++) {
      /*
       * If the last character of the day is a hyphen, we know we need to iterate over the days array we created above.
       */
      if (daysResult[i].charAt(daysResult[i].length - 1) === "-") {
        /*
         * We'll then iterate over the days array we created above and populate it with the time string we've generated.
         */
        for (let l = days.findIndex(e => e === daysResult[i].slice(0, -1)); l < days.length; l++) {
          if (days[l] !== daysResult[i + 1]) {
            returnObj[days[l]] = timeString
          } else {
            /*
             * If the current day isn't the same as the next day in the array, we'll break.
             */
            break;
          }
        }
      } else {
        /*
         * If the last character of the day is not a hyphen, we can just set taht day with the time string we've 
         * generated.
         */
        returnObj[daysResult[i]] = timeString
      }
    }
  }
  /*
   * Display the result alng with the test
   */
  console.log(test, JSON.stringify(returnObj, null, 2))
}

That was fun!

Notes:

Be aware that there's no error checking... because that wasn't in the requirements, though it should be easy enough to add. For instance, we need to consider:

  • In a range of days does the second have an index before the first?
  • Is the start time after the end time?
  • What should happen with duplicate days, such that an date range is then partially or totally replicated within another range, or a single day exists within a previous range?

Monday 26 August 2024

Practicality over Purity

It was Voltaire who said that perfect is the enemy of good; it was me in many interviews who answered, "I'm a perfectionist", whenever anyone asked if I had a weakness. That's not strictly true; it's just something I read years ago about what to answer when asked that question... to be honest, it's good advice and a darn sight better than saying, "Benign dictatorship" when asked about my management style (I still think I would have been brilliant in that role too).

Anyway, it wasn’t precisely Voltaire’s aphorism that prompted me to write this; it was more along the lines of grokking that sometimes, being a purist can get in the way of making a useful thing. Let me explain a little more. I’ve been playing with Web Components for a long time, long before they became as popular as they seem to be now, and whenever I’ve created them, I’ve been conscious that the best place for them would be on npm. Once on npm, they can be imported using skypack or unpkg and used wherever without downloading and hosting them; they should just work (Indeed, whenever I demo them on codepen, that’s what I do to check the mechanism works).

To ensure that as many people find them helpful as possible, I try to make them as perfect as possible, anticipate where they might be used, and make them as flexible as possible. Even in the case of input elements, I try to make them form-associated (though that’s been a massive issue in the past—thanks to a dearth of information on making elements form-associated). This has stopped me from creating and using them in a more bespoke manner up until recently.

Recently, I’ve been involved as a subject-matter-expert (due to suffering a specific condition) with consulting and testing a research tool. I was provided with the underlying questionnaire to be used in that research. We were informed that a development team had been tasked with converting that questionnaire into an online tool which would record answers over days, weeks and months, and I thought that would be a fun way of filling a weekend – to try converting it myself. I’d also been reading about Beer CSS, which aims to translate a modern UI into an HTML semantic standard, which also sounded like a fun tool to play with.

As I started developing the application, I noticed that there would be many repeated code blocks. Each daily question was repeated six times, and the weekly question was repeated fourteen times. Once I started coding it, I noticed that the only difference between the questions was the specific language used and the options. Each question had a radio button to click for the value appropriate to the respondent. This was a perfect place to use a slot within a web component!

I started with the weekly question and copied the markup I’d already implemented:

<article class="large-padding">
  <i>lock_open</i>
  <p>
    <slot></slot>
  </p>
  <div class="grid d-grid-10">
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="0">
        <span class="bold">None</span>
      </label>
    </div>
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="1">
        <span class="bold">A little bit</span>
      </label>
    </div>
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="2">
        <span class="bold">Moderately</span>
      </label>
    </div>
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="3">
        <span class="bold">Quite a bit</span>
      </label>
    </div>
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="4">
        <span class="bold">Extreme</span>
      </label>
    </div>
  </div>
</article>

You’ll no doubt appreciate the sheer amount of copying and pasting to get fourteen of these on the page simultaneously, but this translated into the following template literal within the component:

<article class="large-padding">
  <i${this.#disabled ? ' class="tertiary-text"' : ""}>
    ${this.#locked || this.#disabled ? "lock" : "lock_open"}
  </i>
  <p><slot></slot></p>
  <div class="grid d-grid-10">
    ${this.labels.map((label, i) => `
      <div class="col s10 m5 l2">
        <label class="radio">
          <input type="radio"
                 name="${this.name}"
                 value="${i}"
                 ${this.#value === i ? " checked" : ""}
                 ${this.#locked || this.#disabled ? " disabled" : ""} />
          <span class="bold">${label}</span>
        </label>
      </div>
    `).join("")}
  </div>
</article>

It was much neater, especially as the constructor had the values hard coded:

this.labels = [
  "None",
  "A little bit",
  "Moderately",
  "Quite a bit",
  "Extreme",
];

The keen-eyed amongst you will notice that we have several private values, namely #value, #locked, and #disabled. That’s not to say we don’t expose these values; we can set the value, locked, and disabled attributes, which will update the private values using getters and setters. Further, as we’ve defined the component as being form-associated, when we set the attribute from inside the element, the containing form can be notified of the change by dispatching a change event.

This is the complete code (as always, I’m more than happy to have input into how it might be improved):

import { v4 as uuidv4 } from "https://cdn.skypack.dev/uuid";

class WCEasyQ extends HTMLElement {
  #value = null;
  #locked;
  #disabled;

  static get observedAttributes() {
    return ["value", "name", "locked", "disabled"];
  }
  static formAssociated = true;

  constructor() {
    super();
    this.labels = [
      "None",
      "A little bit",
      "Moderately",
      "Quite a bit",
      "Extreme",
    ];
    this.internals = this.attachInternals();
    this.shadow = this.attachShadow({
      mode: "closed",
      delegatesFocus: true,
    });
    this.name = uuidv4();
  }

  get css() {
    return `
      <style>
        @import url("https://cdn.jsdelivr.net/npm/beercss@3.6.0/dist/cdn/beer.min.css");
        .d-grid-10 {
          margin-block-start: 1rem;
          ---gap: 1rem;
          display: grid;
          grid-template-columns: repeat(
            10,
            calc(10% - var(---gap) + (var(---gap) / 10))
          );
          gap: var(---gap);
        }
        article {
          & i {
            &.tertiary-text {
              cursor: not-allowed !important;
            }
            &:first-child {
              position: absolute;
              top: 10px;
              right: 10px;
              cursor: pointer;
            }
          }
        }
      </style>
    `;
  }

  get html() {
    return `
      <article class="large-padding">
        <i${this.#disabled ? ' class="tertiary-text"' : ""}>
          ${this.#locked || this.#disabled ? "lock" : "lock_open"}
        </i>
        <p><slot></slot></p>
        <div class="grid d-grid-10">
          ${this.labels.map((label, i) => `
            <div class="col s10 m5 l2">
              <label class="radio">
                <input type="radio"
                       name="${this.name}"
                       value="${i}"
                       ${this.#value === i ? " checked" : ""}
                       ${this.#locked || this.#disabled ? " disabled" : ""} />
                <span class="bold">${label}</span>
              </label>
            </div>
          `).join("")}
        </div>
      </article>
    `;
  }

  set value(value) {
    if (value !== null) {
      this.setAttribute("value", Number(value));
      this.#value = Number(value);
    } else {
      this.removeAttribute("value");
      this.#value = null;
    }
    this.internals.setFormValue(this.#value);
  }

  get value() {
    this.#value =
      this.hasAttribute("value") && this.getAttribute("value") !== null
        ? Number(this.getAttribute("value"))
        : null;
    return this.#value;
  }

  set name(value) {
    this.setAttribute("name", this.name);
  }

  get name() {
    return this.hasAttribute("name") && this.getAttribute("name") !== null
      ? this.getAttribute("name")
      : this.name;
  }

  set locked(value) {
    this.#locked = value;
    this.render();
  }

  get locked() {
    this.#locked = this.hasAttribute("locked");
    return this.#locked;
  }

  set disabled(value) {}

  get disabled() {
    this.#disabled = this.hasAttribute("disabled");
    return this.#disabled;
  }

  handleDisabled(value) {
    this.render();
  }

  render() {
    if (this.shadow) {
      this.shadow.removeEventListener("change", this.handleChange);
    }
    if (this.icon) {
      this.icon.removeEventListener("click", this.handleClick);
    }
    this.shadow.innerHTML = `${this.css}${this.html}`;
    this.icon = this.shadow.querySelector("i");
    this.inputs = this.shadow.querySelectorAll("input");
    this.shadow.addEventListener("change", this.handleChange.bind(this));
    this.icon.addEventListener("click", this.handleClick.bind(this));
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      if (name === "locked") {
        this.#locked = this.hasAttribute("locked");
      }
      if (name === "disabled") {
        this.#disabled = this.hasAttribute("disabled");
      }
      if (name === "value") {
        this.value = newValue ? Number(newValue) : null;
      }
      this.render();
    }
  }

  handleClick(event) {
    event.preventDefault();
    this.#locked = !this.#locked;
    this.render();
  }

  handleChange(event) {
    this.value = Number(event.target.value);
    this.dispatchEvent(
      new CustomEvent("change", {
        bubbles: true,
        composed: true,
      }),
    );
  }

  connectedCallback() {
    this.render();
  }
}

customElements.define("wc-easy-question", WCEasyQ);

Except for the hardcoded label values, this is an inherently reusable component. Still, the next element—the daily question elements—was far more custom, not least because it was my first attempt at using a component as a table row. This is the markup I needed to produce:

<tr is="wc-easy-question-row"
    name="Domain-1-1"
    value="2">
  <th class="weight-normal vertical-align-bottom">
    1. Some <span class="bold">strong</span> and important question?
  </th>
  <td class="center-align">
    <label class="radio" title="Not limied at all">
      <input type="radio"
             name="Domain-1-1"
             value="0"
             class="middle center">
      <span></span>
    </label>
  </td>
  <td class="center-align">
    <label class="radio" title="A little limited">
      <input type="radio"
             name="Domain-1-1"
             value="1"
             class="middle center">
      <span></span>
    </label>
  </td>
  <td class="center-align">
    <label class="radio" title="Moderately limited">
      <input type="radio"
             name="Domain-1-1"
             value="2"
             class="middle center"
             checked="">
      <span></span>
    </label>
  </td>
  <td class="center-align">
    <label class="radio" title="Very limited">
      <input type="radio"
             name="Domain-1-1"
             value="3"
             class="middle center">
      <span></span>
    </label>
  </td>
  <td class="center-align">
    <label class="radio" title="Totally limited / unable to do">
      <input type="radio"
             name="Domain-1-1"
             value="4"
             class="middle center">
      <span></span>
    </label>
  </td>
</tr>

As you can see, we’re extending the HTMLTableRowElement and making it form-associated. This is the whole implementation:

import { v4 as uuidv4 } from "https://cdn.skypack.dev/uuid";

class WCEasyQRow extends HTMLTableRowElement {
  #value = null;
  #disabled = false;

  static get observedAttributes() {
    return ["value", "name", "disabled"];
  }

  static formAssociated = true;

  constructor() {
    super();
    this.labels = [
      "Not limied at all",
      "A little limited",
      "Moderately limited",
      "Very limited",
      "Totally limited / unable to do",
    ];
    this.name = uuidv4();
  }

  render() {
    this.removeEventListener("change", this.handleChange);
    const tds = this.querySelectorAll("td");
    for (const td of tds) {
      td.remove();
    }
    this.insertAdjacentHTML("beforeend", this.html);
    this.addEventListener("change", this.handleChange);
  }

  get html() {
    return this.labels
      .map(
        (label, i) => `
          <td class="center-align">
            <label class="radio"
                   title="${label}">
              <input type="radio"
                     name="${this.name}"
                     value="${i}"
                     class="middle center"
                     ${this.#disabled ? "disabled" : ""}
                     ${this.#value === i ? "checked" : ""} />
              <span></span>
            </label>
          </td>`,
      )
      .join("");
  }

  set name(value) {
    this.setAttribute("name", this.name);
  }

  get name() {
    return this.hasAttribute("name") && this.getAttribute("name") !== null
      ? this.getAttribute("name")
      : this.name;
  }

  set disabled(value) {}

  get disabled() {
    this.#disabled = this.hasAttribute("disabled");
    return this.#disabled;
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      if (name === "value") {
        this.#value = newValue ? Number(newValue) : null;
        this.render();
      }
      if (name === "disabled") {
        this.#disabled = this.hasAttribute("disabled");
        this.render();
      }
    }
  }

  connectedCallback() {
    this.render();
  }

  set value(value) {
    this.#value = Number(value);
    this.setAttribute("value", this.#value);
  }

  get value() {
    return this.hasAttribute("value") && this.getAttribute("value") !== null
      ? Number(this.getAttribute("value"))
      : null;
  }

  handleChange(event) {
    this.value = Number(event.target.value);
    if (this.#value !== Number(event.target.value)) {
      this.dispatchEvent(new Event("change"));
    }
  }
}

customElements.define("wc-easy-question-row", WCEasyQRow, {
  extends: "tr",
});

Creating these components didn’t save me much time over copying and pasting the relevant markup and changing the text and names of the radio inputs. Still, it did mean that should I discover an issue when creating the inputs, I only had to address the problem in one file for all the relevant inputs to be fixed simultaneously. And more importantly, it meant the classes would act as templates for future implementations. I’m not adding them to npm as they are only helpful to me, but I can think about abstracting the classes in the future so that they might be more flexible. The second example – the extended table row element – is unsuitable for reuse in any current project I’m working on, but it might be in the future.

Interestingly, I did have a minor issue with them, perhaps due to the sheer number of moving parts. I originally had a render function that ran once when the component mounted. I then did all sorts of interesting internal DOM manipulation, but every so often, the elements would not reflect the changes, so every time something needs to change in the DOM, I re-render it, and things don’t mess up now.

My primary concern is the hard coding of the values, but I’ve read that passing complicated object data in an attribute is considered a bad thing, so I’m not sure how best to address that other than using slots. Sure, I could do complicated things like using JSON strings or Base64 encoded data, but that seems to be getting away from the spirit of web components. I’ve read about passing data using properties. Still, for this example, at least, the only hard-coded values are the labels for the inputs, and they stop the same, so I might as well leave them like that; making them properties might increase the utility of the classes and encourage internalisation and reuse.

Perhaps making them proper web components suitable for use by others and thus worthy of popping into npm might be a job when I have a little time. The inclusion of the specific CSS for the first component might also be an attribute, and this would increase that component's utility.

But, going back to Voltaire, as you’ll doubtless clock from this rambling, while I embraced the less-than-perfect (no utility outside the specific project and no aim to upload to npm) in creating these two components, creating them meant that I could see how they might be made more perfect; I particularly enjoyed the whole locking mechanism, and this is something that I’d like to explore more, though with an appreciation that this might not be required elsewhere – perhaps the locking functionality needs to be made optional before I abstract the class further. You’ll also notice the inclusion of uuidv4 so that, should I forget to add the name when adding the component, the radio buttons will all share the same name, preventing more than one radio button from being checked at a time.

In this instance, I chose Practicality over Purity, and that's fine in my books... for now.