Overview
I had the opportunity to work with a team of fellow developers to create a fully-fledged, interactive component as the culimation of our efforts learning component-based design and development using JavaScript. Using LitElement as our JS web component library, we developed our project with the intent of creating a versatile component that could be adopted for use within the Open-source Hax-the-Web project.
Our team decided to create a sorting game where users are able to sort items in a list-based fashion, either by dragging or via buttons, until the correct order is achieved.
Here is the NPM link if you are interested!
This is the original sorting game we targeted to reproduce as a web component: "Sort the Paragraphs"
Buttons
Dragging
Tutorial
In order to develop this sorting game, our implementation fell into 2 primary elements within the component. One is the frame (SortableFrame) and the other is the reusable option card (SortableOption).
Dragging was particularly challenging for myself specifically to try to wrap my head around. Initially, I went down a very difficult path where I was attempting to great one universal event listener in the SortableFrame that would handle an option every time a drag was started. This meant that I was creating a sortable option, and below each card was an associated droppable area. I then intended to toggle this droppable area depending on whether the card actively being dragged was dropped within the bounds of that drop area.
The problem with this approach was that it was very difficult to discern different types of drag events (dragstart, dragend, drag, drop, etc.). It was also a haggle to re-append drop zones once an option card was moved in the list. I was warned that the edge cases for this approach would be incredibly tedious, so instead our team took the route of applying one drag handler to the sortable option that could be reused within each option. This way, it would only be triggered when that element itself began its drag.
// The Mouse position, drag position, and offSetTop logic was taken nearly directly from Sean's SlimeSorting Implementation
// The overall idea of how to go about dragging to sort each option was taken from Sean as well
drag(ev) {
const pos = ev.clientY;
let currentIndex = 0;
this.dragPosition = this.position - this.offsetTop;
if (pos !== 0) {
this.position = pos;
}
for (let index = 0; index < this.parentElement.children.length; index += 1) {
if (this === this.parentElement.children[index]) {
currentIndex = index;
}
if (window.innerHeight - this.parentElement.clientHeight < 300) {
if (this.offsetTop - this.position > 0) {
// https://stackoverflow.com/questions/9732624/how-to-swap-dom-child-nodes-in-javascript
// https://stackoverflow.com/questions/4793604/how-to-insert-an-element-after-another-element-in-javascript-without-using-a-lib
this.parentElement.insertBefore(this, this.parentElement.children[currentIndex]);
}
if (this.offsetTop - this.position < 40) {
this.parentElement.insertBefore(this, this.parentElement.children[currentIndex + 1].nextElementSibling);
}
} else {
if (this.offsetTop - this.position > 40) {
this.parentElement.insertBefore(this, this.parentElement.children[currentIndex]);
}
if (this.offsetTop - this.position < -60) {
this.parentElement.insertBefore(this,this.parentElement.children[currentIndex + 1].nextElementSibling);
}
}
this.disable();
}
}
I want to give major credit to Sean for not only discovering how to use offsetTop and the cursor's current Y position to calculate the distance dragged before a dragged item should move, but for also taking the time to explain to me how he came about his solution and why it works. All credit goes to him for discovering it and allowing me to use it as well. I really struggled to implement something similar on my own volition. While I would have liked to find a novel solution myself, I ended up following his core cursor position and drag calculation detection logic as the structure of my team's draggable component logic. Please check out Sean and his group's implemention of this card sorting logic, all credit goes to him.
I then went in and worked on editing the parameters to detect our desired drag distance, as well as attempted to put in some additional handling related to how far your have to drag your cursor on smaller screens where not all options may be able to fit on the screen at once.
Another novel approach I thought was really cool was the use of the Fisher-Yates (Knuth) Shuffle algorithm, which was suggested to me via StackOverflow when I was trying to find the best way to randomize the game's options when you first start to play. Here is the source I duplicated to implement this sorting algorithm.
// Fisher-Yates (Knuth) Shuffle
// https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array
let currentIndex = question.answers.length;
let randomIndex;
// While there remain elements to shuffle...
while (currentIndex !== 0) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// And swap it with the current element.
[this.randomized[currentIndex], this.randomized[randomIndex]] = [this.randomized[randomIndex],this.randomized[currentIndex],];
}
Finally, it was also interesting how we ended up disabling the up arrow on the upper-most option and the down arrow on the lowest option. It was suggested to use nextElementSibling as opposed to our original use of nextSibling. nextSibling can actually return a blank, whitespace TextNode as the "next sibling" when items are injected into the DOM, so it was really interesting to come across this issue. This is an explanation. Here is the function for disabling the down arrow:
downbtn() {
if (this.nextElementSibling != null) {
const after = this.nextElementSibling;
document.querySelector('sortable-frame').shadowRoot.querySelector('.frame').querySelector('#options').querySelectorAll('sortable-option')
.forEach(option => {option.shadowRoot.querySelectorAll('button')
.forEach(but => {
// eslint-disable-next-line no-param-reassign
but.disabled = false;
});
});
this.parentNode.insertBefore(after, this);
this.disable();
}
}
Resources
Our team used a number of very helpful resources to assist with our implementation. As suggested by other developers, this CodePen was suggested as one possible way we could re-engineer the sorting aspect of the component. This implementation actually uses an older version of LitElement, so it was very interesting to use this as a baseline even though we really didn't go down this route a ton.
Another cool implementation I found, one that focused more on the design side of a sorting game than the development side, was this awesome post put out by Darin Senneff on 11/8/2021. This design is leagues above our implementation, so I highly recommend checking it out if you want to make your own insanely thorough component.
Another reference I used was suggested to me by a fellow developer as a way to import and process JSON files containing user-generated questions and answers. Using the Hax-the-Web support for modular web components, our team used the HAXSchema to wire-up this game to support user-generated questions. Following a standard JSON format, users who reference a local JSON file containing their questions and answers will be able to support their own sorting game questions! This is the function that was sent to me to help me reverse engineer some support for reading JSON files into the component.
In the project, we also used the SimpleIcon and SimpleIconLite libraries for the arrow icons for the buttons on our card options. Here is a great tutorial.
CodePen and Github links
Note: Dragging kinda works in the embedded CodePen, open the link to fully interact!
A Project EdTechJoker creation
See https://github.com/elmsln/edtechjoker/blob/master/fall-21/projects/p3-haxtheweb/README.md for requirements to complete this project.
Quickstart
To get started:
yarn install
yarn start
# requires node 10 & npm 6 or higher
Navigate to the HAX page to see it in context. Make sure to change rename-me
in ALL parts of the repo to your project name.
Scripts
-
start
runs your app for development, reloading on file changes -
start:build
runs your app after it has been built using the build command -
build
builds your app and outputs it in yourdist
directory -
test
runs your test suite with Web Test Runner -
lint
runs the linter for your project -
format
fixes linting and formatting errors
Tooling configs
For most of the tools, the configuration is in the package.json
to reduce the amount of files in your project.
If you customize the configuration a lot, you can consider moving them to individual files.
NPM Link: https://www.npmjs.com/package/@table-in-the-corner/project-3
Don't hesitate to reach out if you have any questions. Thanks for your interest!
Top comments (1)
Why did you choose Lit over native Web Components?