ts-select
Version:
typescript custom select for web pages
274 lines (207 loc) • 9.64 kB
text/typescript
//utils
function getEl(selector: string, from?: HTMLElement):HTMLElement {
if (from) {
return from.querySelector(selector) as HTMLElement;
}
return document.querySelector(selector) as HTMLElement;
}
//interfaces
interface ISelectItem {
id: number,
value: string,
text: string
}
type TypeSelectList = ISelectItem[];
interface IOptions {
selector: string,
data: TypeSelectList,
imagePath: string,
cb: (selectOption: ISelectItem) => any,
animation?: TypeAnimationSelect,
theme?: TypeTheme,
selectedIndex?: number,
placeholder?: string,
}
interface ISelectCallbacks {
showSelect: () => void,
hideSelect: () => void,
deleteSelect: () => void,
getActiveOption: () => ISelectItem | null,
addSelectOption: (item: ISelectItem) => void,
deleteSelectOption: (id: number) => void,
setActiveOption: (id: number) => void
}
type fnProps<T> = undefined | T;
type TypeVisivility = "show" | "hide";
type TypeTheme = "dark" | "light";
type TypeAnimationSelect = "default" | "opacity" | "slide" | "scale";
//code
function generateCustomSelect(options: IOptions): ISelectCallbacks {
//validations
if (!options.data.length) throw new Error("Incorrect data prop");
let idList: number[] = options.data.map(el => el.id);
if ( idList.length !== Array.from(new Set(idList)).length ) throw new Error("Incorrect id values (not_unique)");
let initialSelectedElementId: number | null = null;
if (options.selectedIndex) {
let element = options.data.filter(el => el.id === options.selectedIndex);
if (!element.length) throw new Error("Incorrect selctedIndex prop");
initialSelectedElementId = element[0].id;
}
//select initialization
const { data, selectedIndex, imagePath, placeholder, cb, theme, animation } = options;
const placeEl = document.querySelector(options.selector);
//validation selector
if (!placeEl) throw new Error("Incorrect selector prop");
const selectElement = document.createElement("div");
selectElement.className = "custom-select";
selectElement.classList.add(theme || "light");
selectElement.classList.add(animation || "default");
selectElement.setAttribute("data-select", String(Date.now()));
const selectMarkup = generateMarkup(data, placeholder, imagePath);
selectElement.innerHTML = selectMarkup;
//select parts
const selectItems: HTMLElement[] = [ ...selectElement.querySelectorAll(".custom-select-body-item") as any ];
const selectTitle = getEl(".custom-select-header-title", selectElement);
const selectHeader = getEl(".custom-select-header", selectElement);
const selectItemsBox = getEl(".custom-select-body", selectElement);
selectItemsBox.classList.add(animation || "default");
(placeEl as HTMLElement).append(selectElement);
if (initialSelectedElementId) setActiveOption(initialSelectedElementId);
let isSelectOpen: boolean = false;
let activeSelectOption: ISelectItem | null = selectedIndex ? data[selectedIndex] : null;
//for keyboard controls
const selectFocusList: HTMLElement[] = [ selectHeader, ...selectItems ];
let lastSelectFocusIndex: number = selectFocusList.length - 1;
let indexSelectFocusList: number = 0;
//handlers
function toggleSelect(status: TypeVisivility):void {
if (status === "show") {
isSelectOpen = true;
selectElement.classList.add("show");
} else {
isSelectOpen = false;
indexSelectFocusList = 0;
selectElement.classList.remove("show");
}
}
function setActiveOption(id: number):void {
const selectedOption = data.filter(el => el.id === id)[0];
if (selectedOption) {
for (let item of selectItems) {
let isPressed = Number(item.dataset.id) === selectedOption.id;
isPressed ? item.classList.add("active") : item.classList.remove("active");
}
selectTitle.textContent = selectedOption.text;
activeSelectOption = selectedOption;
toggleSelect("hide");
selectItems.forEach(el => el.blur());
selectHeader.focus();
cb(selectedOption);
}
}
function addSelectOption(item: ISelectItem):void {
let duble = customFindIndex<ISelectItem>(data, (dataItem) => dataItem.id === item.id);
if (duble !== null) throw new Error("Passed id prop must be unique");
let el = document.createElement("button");
el.classList.add("custom-select-body-item");
el.setAttribute("data-id", String(item.id));
el.setAttribute("tabindex", "-1");
el.textContent = item.text;
selectItemsBox.append(el);
console.log(Array.isArray(selectItems));
selectItems.push(el as HTMLElement);
data.push(item);
selectFocusList.push(el);
lastSelectFocusIndex++;
}
function deleteSelectOption(id: number):void {
if (data.length === 1) throw new Error("Options select list not can be empty");
let findedIndex = customFindIndex<ISelectItem>(data, (dataItem) => dataItem.id === id);
if (!findedIndex) throw new Error("Not found id in the select option list");
let findedIndexFocusList = customFindIndex<HTMLElement>(selectFocusList, (el) => Number(el.dataset.id) === id);
if (findedIndexFocusList) selectFocusList.splice(findedIndexFocusList, 1);
selectItems[findedIndex].remove();
selectItems.splice(findedIndex, 1);
data.splice(findedIndex, 1);
lastSelectFocusIndex--;
}
const getActiveOption = ():ISelectItem | null => activeSelectOption;
const deleteSelect = ():void => {
selectElement.removeEventListener("click", handlerSelectClick);
document.documentElement.removeEventListener("click", handlerSelectClose);
document.documentElement.removeEventListener("keydown", handlerSelectKey);
selectElement.remove();
}
const showSelect = ():void => toggleSelect("show");
const hideSelect = ():void => toggleSelect("hide");
//handlers
function handlerSelectKey(e: KeyboardEvent):void {
if (isSelectOpen) {
let code = e.code;
if (code === "ArrowUp") {
indexSelectFocusList = indexSelectFocusList ? indexSelectFocusList - 1 : lastSelectFocusIndex;
selectFocusList[indexSelectFocusList].focus();
return;
}
if (code === "ArrowDown") {
indexSelectFocusList = indexSelectFocusList === lastSelectFocusIndex ? 0 : indexSelectFocusList + 1;
selectFocusList[indexSelectFocusList].focus();
return;
}
}
}
function handlerSelectClose(e: MouseEvent):void {
if (isSelectOpen) {
let target = e.target as HTMLElement;
let pressedEl = target.closest(".custom-select");
if (pressedEl) {
if ((pressedEl as HTMLElement).dataset.select !== selectElement.dataset.select) toggleSelect("hide");
} else {
toggleSelect("hide");
}
}
}
function handlerSelectClick(e: MouseEvent):void {
const target = e.target as HTMLElement;
let pressedHeaderSelect = target.closest(".custom-select-header");
if (pressedHeaderSelect) {
toggleSelect(isSelectOpen ? "hide" : "show");
return;
}
let pressedSelectOption = target.matches(".custom-select-body-item");
if (pressedSelectOption) {
let chooseId = Number(target.dataset.id);
setActiveOption(chooseId);
}
}
//setup handlers
selectElement.addEventListener("click", handlerSelectClick);
document.documentElement.addEventListener("click", handlerSelectClose);
document.documentElement.addEventListener("keydown", handlerSelectKey);
return { showSelect, hideSelect, deleteSelect, getActiveOption, addSelectOption, deleteSelectOption, setActiveOption };
}
function generateMarkup(data: TypeSelectList, placeholder: fnProps<string>, imagePath: string):string {
let headerSelectContent = `
<button class="custom-select-header">
<div class="custom-select-header-title">${ getInitialSelectTitle(placeholder) }</div>
<img class="custom-select-header-img" src="${imagePath}" alt="стрелка">
</button>
`;
let values = data.map(el => `<button tabindex="-1" data-id="${el.id}" class="custom-select-body-item">${el.text}</button>`);
let bodySelectContent = ` <div class="custom-select-body">${ values.join("") }</div> `;
return `${headerSelectContent}\n${bodySelectContent}`;
}
function getInitialSelectTitle(placeholder: fnProps<string>):string {
if (placeholder && placeholder.trim()) return placeholder;
return "Выберите значение";
}
function customFindIndex <T>(list: T[], cb: (el: T) => boolean):number | null {
let index = 0;
while (index < list.length) {
let result = cb(list[index]);
if (result) return index;
index++;
}
return null;
}
export { generateCustomSelect, IOptions, ISelectCallbacks };