import { injectable } from "@joist/di";
import {
attr,
attrChanged,
css,
element,
html,
listen,
query,
} from "@joist/element";
import { COMBO_BOX_CTX, type ComboBoxContainer } from "./context.js";
declare global {
interface HTMLElementTagNameMap {
"usa-combo-box": USAComboBoxElement;
}
}
@injectable({
name: "usa-combo-box-ctx",
provideSelfAs: [COMBO_BOX_CTX],
})
@element({
tagName: "usa-combo-box",
shadowDom: [
css`
* {
box-sizing: border-box
}
:host {
display: block;
max-width: 30rem;
position: relative;
margin-bottom: 1.5rem;
}
input {
border-width: 1px;
border-color: #5c5c5c;
border-style: solid;
border-radius: 0;
color: #1b1b1b;
background-color: #fff;
display: block;
height: 2.5rem;
line-height: 1.3;
font-size: 1.06rem;
margin-top: 0.5rem;
padding: 0.5rem;
width: 100%;
padding-right: 2.8rem;
}
input:not(:disabled):focus {
outline: 0.25rem solid #2491ff;
outline-offset: 0;
}
input:disabled {
background-color: #fff;
border-color: #757575;
color: #757575;
}
ul {
padding: 0;
position: absolute;
bottom: 0;
width: 100%;
transform: translateY(100%);
margin: 0;
border: 1px solid #5c5c5c;
max-height: 12.5em;
overflow-y: scroll;
z-index: 1001;
}
ul:empty {
border: none;
}
ul li {
background: #fff;
list-style: none;
border-bottom: 1px solid #e6e6e6;
cursor: pointer;
display: block;
}
ul li:hover {
background-color: #f0f0f0;
}
li:focus {
outline: 0.25rem solid #2491ff;
outline-offset: -0.25rem;
}
.usa-combo-box-icon {
display: block;
position: absolute;
bottom: 0rem;
right: 0.5rem;
cursor: pointer;
}
.usa-combo-box-icon .line {
width: 1px;
top: 0.3rem;
bottom: 0.5rem;
left: -0.3rem;
position: absolute;
background-color: #c6cace;
}
`,
html`
`,
],
})
export class USAComboBoxElement
extends HTMLElement
implements ComboBoxContainer
{
static formAssociated = true;
@attr()
accessor name = "";
@attr()
accessor required = false;
@attr()
accessor value = "";
@attr()
accessor placeholder = "";
@attr()
accessor disabled = false;
list = query("ul");
input = query("input");
currentItemEl: Element | null = null;
#allListItems = new Set();
#internals = this.attachInternals();
attributeChangedCallback() {
this.input({
name: this.name,
placeholder: this.placeholder,
required: this.required,
disabled: this.disabled,
});
}
@attrChanged("value", "required")
onValueChanged() {
this.#syncFormState();
}
connectedCallback() {
this.#syncFormState();
}
listItems() {
return this.list().querySelectorAll("li");
}
addOption(el: HTMLLIElement) {
this.#allListItems.add(el);
}
removeOption(el: HTMLLIElement) {
this.#allListItems.delete(el);
}
@listen("focus", (host) => host.input())
onFocusIn() {
if (this.disabled) {
return;
}
this.currentItemEl = null;
const list = this.list();
this.input({ ariaExpanded: "true" });
const fragment = document.createDocumentFragment();
for (const item of this.#allListItems) {
fragment.append(item);
}
list.replaceChildren(fragment);
}
focus(options?: FocusOptions): void {
this.input().focus(options);
}
blur(): void {
this.input().blur();
}
@listen("input")
async onInput() {
if (this.disabled) {
return;
}
const input = this.input();
const list = this.list();
this.currentItemEl = null;
const fragment = document.createDocumentFragment();
for (const item of this.#allListItems) {
if (
item.dataset.value?.toLowerCase().startsWith(input.value.toLowerCase())
) {
fragment.append(item);
}
}
list.replaceChildren(fragment);
}
@listen("focusout")
onFocusOut() {
setTimeout(() => {
// This needs to be in a timeout so that it runs as part of the next loop.
// the active element will not be set until after all of the focus and blur events are done
if (!this.contains(document.activeElement)) {
this.list({ innerHTML: "" });
this.currentItemEl = null;
this.input({ ariaExpanded: "false" });
}
}, 0);
}
@listen("keydown")
onArrowDown(e: KeyboardEvent): void {
if (e.key.toUpperCase() !== "ARROWDOWN") {
return;
}
e.preventDefault();
if (this.currentItemEl === null) {
// if there is no current item, set the first item as the current item
const list = this.list();
this.currentItemEl = list.firstElementChild;
} else if (this.currentItemEl.nextSibling) {
// if there is a current item, set the next item as the current item
this.currentItemEl = this.currentItemEl.nextElementSibling;
}
if (this.currentItemEl instanceof HTMLElement) {
this.currentItemEl.focus();
}
}
@listen("keydown")
onArrowUp(e: KeyboardEvent): void {
if (e.key.toUpperCase() !== "ARROWUP") {
return;
}
e.preventDefault();
if (this.currentItemEl?.previousElementSibling) {
this.currentItemEl = this.currentItemEl.previousElementSibling;
if (this.currentItemEl instanceof HTMLElement) {
this.currentItemEl.focus();
}
} else {
this.input().focus();
this.currentItemEl = null;
}
}
@listen("keydown")
onEnter(e: KeyboardEvent): void {
if (e.key.toUpperCase() !== "ENTER") {
return;
}
e.preventDefault();
const target = e.target as HTMLElement;
this.currentItemEl = null;
const value = target.dataset.value || "";
this.input().focus();
this.list({ innerHTML: "" });
this.value = value;
}
@listen("click")
onClick(e: MouseEvent) {
if (e.target instanceof HTMLElement) {
const value = e.target.getAttribute("value");
if (value) {
this.input().focus();
this.list({ innerHTML: "" });
this.currentItemEl = null;
this.value = value;
}
}
}
#syncFormState() {
const input = this.input({ value: this.value });
this.#internals.setValidity({});
this.#internals.setFormValue(this.value);
if (input.validationMessage) {
this.#internals.setValidity(
{ customError: true },
input.validationMessage,
input,
);
}
}
}