Skip to content

Instantly share code, notes, and snippets.

@CodeMyUI
Created February 5, 2020 22:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save CodeMyUI/7ab66caded6999d6c77679f01243c630 to your computer and use it in GitHub Desktop.
Save CodeMyUI/7ab66caded6999d6c77679f01243c630 to your computer and use it in GitHub Desktop.
Folding Select Dropdowns
<form>
<label for="os">OS</label>
<select id="os" name="os">
<option value="none" selected>Select…</option>
<option value="linux">Linux</option>
<option value="macos">macOS</option>
<option value="windows">Windows</option>
</select>
<label for="fruit">Fruit</label>
<select id="fruit" name="fruit">
<option value="none" selected>Select…</option>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
<option value="durian">Durian</option>
</select>
</form>
document.addEventListener("DOMContentLoaded",() => {
const os = new SelectDropdown({ id: "os" }),
fruit = new SelectDropdown({ id: "fruit" });
});
class SelectDropdown {
constructor(args) {
this.isOpening = false;
this.select = document.querySelector(`select[id=${args.id}]`);
this.selectBtn = document.createElement("button");
this.options = document.createElement("div");
this.buildDropdown();
}
buildDropdown() {
if (this.select !== null) {
// create div to contain <select>
let wrapper = document.createElement("div");
wrapper.setAttribute("class","select");
this.select.parentElement.insertBefore(wrapper,this.select);
wrapper.appendChild(this.select);
// create button
let id = this.select.id,
selectBtnAttrs = {
"class": "select__button select__button--pristine",
"type": "button",
"id": `${id}-options`,
"aria-haspopup": "listbox",
"aria-expanded": "false"
};
for (let a in selectBtnAttrs)
this.selectBtn.setAttribute(a,selectBtnAttrs[a]);
let selectBtnText = document.createTextNode(this.select.options[0].innerHTML);
this.selectBtn.appendChild(selectBtnText);
wrapper.appendChild(this.selectBtn);
// create options div
let optionsAttrs = {
"class": "select__options",
"aria-labelledby": selectBtnAttrs.id
};
for (let a in optionsAttrs)
this.options.setAttribute(a,optionsAttrs[a]);
// then each option
for (let o of this.select.options) {
let option = document.createElement("a"),
optionText = document.createTextNode(o.innerHTML),
optionAttrs = {
"href": "#",
"class": "select__option",
"data-value": o.value,
"role": "option",
"aria-selected": "false"
};
for (let a in optionAttrs)
option.setAttribute(a,optionAttrs[a]);
option.appendChild(optionText);
this.options.appendChild(option);
}
wrapper.appendChild(this.options);
// sync with pre-selected option
let preselected = this.options.querySelector(`[data-value=${this.select.value}]`);
preselected.setAttribute("aria-selected",true);
this.selectBtn.innerHTML = preselected.innerHTML;
// restack so options can appear over other dropdowns
let selects = document.querySelectorAll(".select"),
selectCount = 0;
while (selectCount < selects.length) {
selects[selectCount].style.zIndex = selects.length - selectCount;
++selectCount;
}
// assign event listeners
document.querySelector(`label[for=${id}]`).addEventListener("click",() => {
if (!this.isExpanded())
this.selectBtn.focus();
else
this.closeSelect();
});
this.selectBtn.addEventListener("click",() => { this.openSelect(); });
this.options.addEventListener("click",e => { this.closeSelect(e); });
document.addEventListener("click",() => {
let el = document.activeElement;
if (el.parentElement.getAttribute("aria-labelledby") !== selectBtnAttrs.id)
this.closeSelect();
});
window.addEventListener("keydown",e => {
switch (e.keyCode) {
case 27: // Esc
this.closeSelect();
break;
case 32: // Spacebar
this.closeSelect(e);
break;
case 38: // Up
this.goToOption("previous");
break;
case 40: // Down
this.goToOption("next");
break;
default:
break;
}
});
}
}
isExpanded() {
return this.selectBtn.getAttribute("aria-expanded") === "true";
}
openSelect(e) {
if (!this.isExpanded()) {
// prevent immediate closing to not ruin animation
let foldDur = window.getComputedStyle(this.options);
foldDur = foldDur.getPropertyValue("transition-delay").split("");
if (foldDur.indexOf("m") > -1) {
foldDur.splice(foldDur.length - 2,2);
foldDur = parseInt(foldDur.join(""));
} else if (foldDur.indexOf("s") > -1) {
foldDur.pop();
foldDur = parseFloat(foldDur.join("")) * 1e3;
}
this.isOpening = true;
setTimeout(() => {this.isOpening = false;},foldDur);
// manage states
this.selectBtn.setAttribute("aria-expanded",true);
let btnClasses = this.selectBtn.classList,
pristineClass = "select__button--pristine",
animClass = `select__button--fold${this.select.options.length}`;
if (btnClasses.contains(pristineClass)) {
btnClasses.remove(pristineClass);
} else {
btnClasses.remove(animClass);
void this.selectBtn.offsetWidth;
}
btnClasses.add(animClass);
// set focus to selected option
let selected = this.options.querySelector("[aria-selected=true]");
if (selected !== null)
selected.focus();
else
this.options.childNodes[0].focus();
}
}
closeSelect(e) {
if (this.isExpanded() && !this.isOpening) {
if (e && e.target !== this.options.childNodes[0]) {
// update values of both original and custom dropdowns
this.select.value = e.target.getAttribute("data-value");
this.selectBtn.innerHTML = e.target.innerHTML;
// indicate selected item
for (let n of this.options.childNodes)
n.setAttribute("aria-selected",false);
e.target.setAttribute("aria-selected",true);
e.preventDefault();
}
this.selectBtn.setAttribute("aria-expanded",false);
this.selectBtn.focus();
// fire animation
let btnClasses = this.selectBtn.classList,
animClass = `select__button--fold${this.select.options.length}`;
btnClasses.remove(animClass);
void this.selectBtn.offsetWidth;
btnClasses.add(animClass);
}
}
goToOption(goTo) {
if (this.isExpanded()) {
let optionLinks = this.options.querySelectorAll("a"),
activeEl = document.activeElement,
linkFound = false;
// check for focused option
for (let l of optionLinks) {
if (activeEl === l) {
linkFound = true;
break;
}
}
// allow movement with arrows until top or bottom option is reached
if (linkFound) {
if (goTo === "previous" && activeEl !== optionLinks[0])
activeEl.previousSibling.focus();
else if (goTo === "next" && activeEl !== optionLinks[optionLinks.length - 1])
activeEl.nextSibling.focus();
}
}
}
}
* {
border: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--foldDur: 0.7s;
font-size: calc(24px + (30 - 24)*(100vw - 320px)/(1280 - 320));
}
body, button {
color: #171717;
font: 1em Gaegu, cursive;
line-height: 1.5;
}
body {
background-image:
linear-gradient(#8ccfd100 1.35em,#8ccfd1 1.4em 1.45em,#8ccfd100 1.5em),
linear-gradient(90deg,#e5e5e5 1.35em,#8ccfd1 1.4em 1.45em,#e5e5e5 1.5em);
background-position: 50% 0;
background-size: 1.5em 1.5em;
}
form, .select__button, .select__options {
width: 100%;
}
form {
margin: auto;
padding: 3em 1.5em 6em 1.5em;
max-width: 13.25em;
}
label {
display: block;
font-weight: bold;
}
.select, .select__button {
position: relative;
}
.select {
margin-bottom: 1.5em;
perspective: 20em;
}
.select select {
display: none;
}
.select__button, .select__option {
background: #f1f1f1;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.375em;
}
.select__button {
animation-duration: var(--foldDur);
animation-timing-function: ease-in-out;
animation-direction: reverse;
border-radius: 0;
box-shadow:
-0.05em 0 0 #fff inset,
0.05em 0 0 #ccc inset,
0 -0.05em 0 #d9d9d9 inset;
transform-origin: 50% 100%;
-webkit-appearance: none;
appearance: none;
z-index: 1;
}
.select__button:focus, .select__option:focus {
outline: transparent;
}
.select__button:focus {
color: #2762f3;
}
.select__button:after, .select__option:first-child:after {
border-left: 0.25em solid transparent;
border-right: 0.25em solid transparent;
content: "";
display: block;
width: 0;
height: 0;
}
.select__button:after {
border-top: 0.25em solid;
}
.select__button--pristine {
animation: none;
}
.select__options {
position: absolute;
top: 0;
left: 0;
visibility: hidden;
transition: visibility 0s var(--foldDur) steps(1);
}
.select__option {
box-shadow:
-0.05em -0.1em 0.2em 0 #0001 inset,
0.05em 0 0 #ccc inset,
-0.05em 0.1em 0.2em 0 #fff inset,
-0.05em 0 0 #fff inset;
color: inherit;
opacity: 0;
text-decoration: none;
transition: opacity 0s var(--foldDur) steps(1);
}
.select__option:first-child {
box-shadow:
-0.05em -0.1em 0.2em 0 #0001 inset,
0.05em 0 0 #ccc inset,
-0.05em 0 0 #fff inset;
}
.select__option:first-child:after {
border-bottom: 0.25em solid;
}
.select__option:hover, .select__option:focus {
background: #b8ccf9;
box-shadow:
-0.05em -0.1em 0.2em 0 #0001 inset,
0.05em 0 0 #a9bbe5 inset,
-0.05em 0 0 #c1d4ff inset;
}
.select__option[aria-selected="true"]:not(:first-child):after {
content: "\2713";
}
/* Animation */
.select__button[aria-expanded="true"] {
animation-direction: normal;
animation-fill-mode: forwards;
box-shadow:
-0.05em -0.1em 0.2em 0 #fff inset,
-0.05em 0 0 #fff inset;
cursor: default;
pointer-events: none;
}
.select__button--fold4 {
animation-name: fold4;
}
.select__button--fold5 {
animation-name: fold5;
}
.select__button[aria-expanded="true"] + .select__options,
.select__button[aria-expanded="true"] + .select__options .select__option {
transition-delay: 0s;
}
.select__button--fold4[aria-expanded="true"] + .select__options,
.select__button--fold5[aria-expanded="true"] + .select__options {
visibility: visible;
}
.select__button--fold4[aria-expanded="true"] + .select__options .select__option,
.select__button--fold5[aria-expanded="true"] + .select__options .select__option {
opacity: 1;
}
.select__button--fold4 + .select__options .select__option:nth-child(4),
.select__button--fold4[aria-expanded="true"] + .select__options .select__option:nth-child(2) {
transition-delay: calc(var(--foldDur) * 0.25);
}
.select__button--fold4 + .select__options .select__option:nth-child(3),
.select__button--fold4[aria-expanded="true"] + .select__options .select__option:nth-child(3) {
transition-delay: calc(var(--foldDur) * 0.5);
}
.select__button--fold4 + .select__options .select__option:nth-child(2),
.select__button--fold4[aria-expanded="true"] + .select__options .select__option:nth-child(4) {
transition-delay: calc(var(--foldDur) * 0.75);
}
.select__button--fold5 + .select__options .select__option:nth-child(5),
.select__button--fold5[aria-expanded="true"] + .select__options .select__option:nth-child(2) {
transition-delay: calc(var(--foldDur) * 0.2);
}
.select__button--fold5 + .select__options .select__option:nth-child(4),
.select__button--fold5[aria-expanded="true"] + .select__options .select__option:nth-child(3) {
transition-delay: calc(var(--foldDur) * 0.4);
}
.select__button--fold5 + .select__options .select__option:nth-child(3),
.select__button--fold5[aria-expanded="true"] + .select__options .select__option:nth-child(4) {
transition-delay: calc(var(--foldDur) * 0.6);
}
.select__button--fold5 + .select__options .select__option:nth-child(2),
.select__button--fold5[aria-expanded="true"] + .select__options .select__option:nth-child(5) {
transition-delay: calc(var(--foldDur) * 0.8);
}
@keyframes appear {
from { opacity: 0 }
to { opacity: 1 }
}
@keyframes fold4 {
from { color: #171717; transform: translateY(0) rotateX(0deg) }
12.5% { color: #171717; transform: translateY(0) rotateX(-90deg) }
12.51% { color: transparent; transform: translateY(0) rotateX(-90deg) }
25% { color: transparent; transform: translateY(0) rotateX(-180deg) }
25.01% { color: transparent; transform: translateY(100%) rotateX(0deg) }
50% { color: transparent; transform: translateY(100%) rotateX(-180deg) }
50.01% { color: transparent; transform: translateY(200%) rotateX(0deg) }
75% { color: transparent; transform: translateY(200%) rotateX(-180deg) }
75.01% { color: transparent; transform: translateY(300%) rotateX(0deg) }
to { color: transparent; transform: translateY(300%) rotateX(-100deg) }
}
@keyframes fold5 {
from { color: #171717; transform: translateY(0) rotateX(0deg) }
10% { color: #171717; transform: translateY(0) rotateX(-90deg) }
10.01% { color: transparent; transform: translateY(0) rotateX(-90deg) }
20% { color: transparent; transform: translateY(0) rotateX(-180deg) }
20.01% { color: transparent; transform: translateY(100%) rotateX(0deg) }
40% { color: transparent; transform: translateY(100%) rotateX(-180deg) }
40.01% { color: transparent; transform: translateY(200%) rotateX(0deg) }
60% { color: transparent; transform: translateY(200%) rotateX(-180deg) }
60.01% { color: transparent; transform: translateY(300%) rotateX(0deg) }
80% { color: transparent; transform: translateY(300%) rotateX(-180deg) }
80.01% { color: transparent; transform: translateY(400%) rotateX(0deg) }
to { color: transparent; transform: translateY(400%) rotateX(-100deg) }
}
<link href="https://fonts.googleapis.com/css?family=Gaegu:400,700&amp;display=swap" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment