Skip to content

Instantly share code, notes, and snippets.

@yawaramin
Last active June 4, 2025 21:09
Show Gist options
  • Save yawaramin/088d2d86eddbb2a8f1da01358d2909e9 to your computer and use it in GitHub Desktop.
Save yawaramin/088d2d86eddbb2a8f1da01358d2909e9 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Combo Box</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css">
</head>
<body>
<main class="m-4">
<h1 class="is-size-1">Combo Box</h1>
<div class="field is-horizontal">
<div class="field-label">
<label class="label" for="shipping-country">Shipping Country</label>
</div>
<div class="field-body">
<div class="field">
<combo-box class="dropdown">
<div class="control dropdown-trigger has-icons-right">
<input class=input aria-haspopup=true aria-controls="shipping-country-dropdown-menu" id="shipping-country"
name="shipping-country" value="United Kingdom">
<span class="icon is-small is-right">🔎</span>
</div>
<div class="dropdown-menu" id="shipping-country-dropdown-menu" role="menu">
<div class="dropdown-content">
<a class=dropdown-item tabindex="0" data-value="Austria">Austria</a>
<a class="dropdown-item" tabindex="1" data-value="Azerbaijan">Azerbaijan</a>
<a class="dropdown-item is-active" tabindex="2" data-value="United Kingdom">United Kingdom</a>
<a class="dropdown-item" tabindex="3" data-value="United States">United States</a>
</div>
</div>
</combo-box>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label" for="billing-country">Billing Country</label>
</div>
<div class="field-body">
<div class="field">
<combo-box class="dropdown">
<div class="control dropdown-trigger has-icons-right">
<input class=input aria-haspopup=true aria-controls="billing-country-dropdown-menu" id="billing-country"
name="billing-country" value="United Kingdom">
<span class="icon is-small is-right">🔎</span>
</div>
<div class="dropdown-menu" id="billing-country-dropdown-menu" role="menu">
<div class="dropdown-content">
<a class=dropdown-item tabindex="0" data-value="Austria">Austria</a>
<a class="dropdown-item" tabindex="1" data-value="Azerbaijan">Azerbaijan</a>
<a class="dropdown-item is-active" tabindex="2" data-value="United Kingdom">United Kingdom</a>
<a class="dropdown-item" tabindex="3" data-value="United States">United States</a>
</div>
</div>
</combo-box>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label" for="fruit">Fruit</label>
</div>
<div class="field-body">
<div class="field">
<combo-box class=dropdown list="fruits" key="fruit">
<div class="control dropdown-trigger has-icons-right">
<input class=input aria-haspopup=true>
<span class="icon is-small is-right">🔎</span>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
</div>
</div>
</combo-box>
</div>
</div>
</div>
</main>
<datalist id="fruits">
<option value="Apple"></option>
<option value="Banana"></option>
<option value="Cherry"></option>
<option value="Durian"></option>
<option value="Mango"></option>
<option value="Pear"></option>
<option value="Plum"></option>
<option value="Strawberry"></option>
</datalist>
<script>
(() => {
const IS_ACTIVE = 'is-active';
const TAB = 9;
const ESC = 27;
const UP_ARROW = 38;
const DOWN_ARROW = 40;
function hide(el) {
el?.classList.add('is-hidden');
}
function show(el) {
el?.classList.remove('is-hidden');
}
function activate(el) {
el?.classList.add(IS_ACTIVE);
}
function deactivate(el) {
el?.classList.remove(IS_ACTIVE);
}
function isActive(el) {
return el?.classList.contains(IS_ACTIVE);
}
function h(obj) {
if (typeof (obj) == 'string') {
return document.createTextNode(obj);
}
const elem = document.createElement(obj.tag);
for (const attr in obj.attrs) {
elem.setAttribute(attr, obj.attrs[attr]);
}
for (const child of obj.children) {
elem.appendChild(h(child));
}
return elem;
}
customElements.define('combo-box', class extends HTMLElement {
#selected = null;
#numItems = 0;
#listObserver = null;
#list = null;
static get observedAttributes() {
return ['form', 'key', 'list', 'value'];
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'form':
this.querySelector('input').setAttribute('form', newValue);
break;
case 'key':
this.key = newValue;
break;
case 'list':
this.listId = newValue;
break;
case 'value':
this.querySelector('input').setAttribute('value', newValue);
break;
}
}
set key(value) {
if (value == null) {
return;
}
const inp = this.querySelector('input');
inp.id = value;
inp.name = value;
const menuId = `${value}-dropdown-menu`;
inp.setAttribute('aria-controls', menuId);
this.querySelector('.dropdown-menu').id = menuId;
}
set listId(value) {
if (value == null || value == this.#list) {
return;
}
this.#list = value;
const dropdownContent = this.querySelector('.dropdown-content');
const options = document.querySelectorAll(`#${value} > option`);
dropdownContent.textContent = '';
for (const option of options) {
dropdownContent.appendChild(h({
tag: 'a',
attrs: {
class: 'dropdown-item',
tabindex: this.#numItems++,
'data-value': option.value,
role: 'menuitem',
},
children: [option.textContent || option.value],
}));
}
this.#listObserver?.disconnect();
this.#listObserver?.observe(document.getElementById(value), {
attributes: true,
attributeFilter: ['value'],
attributeOldValue: true,
childList: true,
subtree: true,
});
}
set active(value) {
if (value) {
activate(this);
} else {
deactivate(this);
}
}
shownItems() {
return this.querySelectorAll('.dropdown-item:not(.is-hidden)');
}
constructor() {
super();
const dropdownContent = this.querySelector('.dropdown-content');
const listId = this.getAttribute('list');
if (listId != null) {
this.#listObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
switch (mutation.type) {
case 'attributes':
if (mutation.target.nodeName == 'OPTION' && mutation.attributeName == 'value') {
const item = this.querySelector(`.dropdown-item[data-value="${mutation.oldValue}"]`);
item.setAttribute('data-value', mutation.target.value);
item.textContent = mutation.target.textContent || mutation.target.value;
}
break;
case 'childList':
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeName == 'OPTION') {
dropdownContent.appendChild(h({
tag: 'a',
attrs: {
class: 'dropdown-item',
tabindex: this.#numItems++,
'data-value': addedNode.value,
},
children: [addedNode.textContent || addedNode.value],
}));
}
}
for (const removedNode of mutation.removedNodes) {
if (removedNode.nodeName == 'OPTION') {
const nodeValue = removedNode.getAttribute('value');
dropdownContent.removeChild(this.querySelector(`.dropdown-item[data-value="${nodeValue}"]`));
}
}
break;
}
}
});
}
}
connectedCallback() {
const inp = this.querySelector('input');
this.key = this.getAttribute('key');
const items = this.querySelectorAll('.dropdown-item');
this.#numItems = items.length;
inp.addEventListener('click', () => {
if (!isActive(this)) {
activate(this);
}
});
inp.addEventListener('keydown', evt => {
switch (evt.keyCode) {
case ESC:
inp.blur();
deactivate(this);
break;
case TAB:
deactivate(this);
break;
case DOWN_ARROW:
const shownItems = this.shownItems();
if (shownItems.length > 0) {
shownItems[0].focus();
}
break;
}
});
inp.addEventListener('keyup', evt => {
if (!isActive(this)) {
activate(this);
}
if (isActive(this.#selected)) {
deactivate(this.#selected);
this.#selected = null;
}
for (const item of items) {
if (item.textContent.toLowerCase().includes(inp.value.toLowerCase())) {
show(item);
} else {
hide(item);
}
if (item.textContent == inp.value) {
deactivate(this.#selected);
activate(item);
this.#selected = item;
}
}
});
for (const item of items) {
item.role = 'menuitem';
if (isActive(item)) {
this.#selected = item;
}
item.addEventListener('click', evt => {
evt.preventDefault();
inp.value = item.getAttribute('data-value');
deactivate(this.#selected);
activate(item);
this.#selected = item;
deactivate(this);
});
item.addEventListener('keydown', evt => {
switch (evt.keyCode) {
case DOWN_ARROW:
for (const it of this.shownItems()) {
if (it.tabIndex > item.tabIndex) {
it.focus();
return;
}
}
break;
case UP_ARROW:
const visibleItems = this.shownItems();
for (let idx = visibleItems.length - 1; idx >= 0; idx--) {
if (visibleItems[idx].tabIndex < item.tabIndex) {
visibleItems[idx].focus();
return;
}
}
inp.focus();
break;
case ESC:
deactivate(this);
break;
case 13: // Enter
case 32: // Space
item.click();
break;
}
});
}
}
});
document.addEventListener('click', () => {
for (const combo of document.querySelectorAll('combo-box')) {
if (!combo.contains(event.target)) {
combo.active = false;
}
}
})
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment