Dominic Myers writes about all sorts of stuff to do with HTML, CSS, JavaScript and a fair chunk of self-indulgent stuff. Thoughts and opinions are all his own, and nothing to do with any employer.
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?
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:
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:
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):
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:
<tris="wc-easy-question-row"name="Domain-1-1"value="2"><thclass="weight-normal vertical-align-bottom">
1. Some <spanclass="bold">strong</span> and important question?
</th><tdclass="center-align"><labelclass="radio"title="Not limied at all"><inputtype="radio"name="Domain-1-1"value="0"class="middle center"><span></span></label></td><tdclass="center-align"><labelclass="radio"title="A little limited"><inputtype="radio"name="Domain-1-1"value="1"class="middle center"><span></span></label></td><tdclass="center-align"><labelclass="radio"title="Moderately limited"><inputtype="radio"name="Domain-1-1"value="2"class="middle center"checked=""><span></span></label></td><tdclass="center-align"><labelclass="radio"title="Very limited"><inputtype="radio"name="Domain-1-1"value="3"class="middle center"><span></span></label></td><tdclass="center-align"><labelclass="radio"title="Totally limited / unable to do"><inputtype="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() {
returnthis.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() {
returnthis.hasAttribute("name") &&this.getAttribute("name") !==null?this.getAttribute("name")
:this.name;
}
set disabled(value) {}
get disabled() {
this.#disabled =this.hasAttribute("disabled");
returnthis.#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() {
returnthis.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.