choices.js
Version:
A vanilla JS customisable text input/select box plugin
410 lines (339 loc) • 12.2 kB
text/typescript
/**
* Helpers to create HTML elements used by Choices
* Can be overridden by providing `callbackOnCreateTemplates` option.
* `Choices.defaults.templates` allows access to the default template methods from `callbackOnCreateTemplates`
*/
import { ChoiceFull } from './interfaces/choice-full';
import { GroupFull } from './interfaces/group-full';
import { PassedElementType } from './interfaces/passed-element-type';
import { StringPreEscaped } from './interfaces/string-pre-escaped';
import {
getClassNames,
unwrapStringForRaw,
resolveNoticeFunction,
setElementHtml,
escapeForTemplate,
addClassesToElement,
removeClassesFromElement,
} from './lib/utils';
import { NoticeType, NoticeTypes, TemplateOptions, Templates as TemplatesInterface } from './interfaces/templates';
import { StringUntrusted } from './interfaces/string-untrusted';
const isEmptyObject = (obj: object): boolean => {
// eslint-disable-next-line no-restricted-syntax
for (const prop in obj) {
if (Object.prototype.hasOwnProperty.call(obj, prop)) {
return false;
}
}
return true;
};
const assignCustomProperties = (el: HTMLElement, choice: ChoiceFull, withCustomProperties: boolean): void => {
const { dataset } = el;
const { customProperties, labelClass, labelDescription } = choice;
if (labelClass) {
dataset.labelClass = getClassNames(labelClass).join(' ');
}
if (labelDescription) {
dataset.labelDescription = labelDescription;
}
if (withCustomProperties && customProperties) {
if (typeof customProperties === 'string') {
dataset.customProperties = customProperties;
} else if (typeof customProperties === 'object' && !isEmptyObject(customProperties)) {
dataset.customProperties = JSON.stringify(customProperties);
}
}
};
const addAriaLabel = (docRoot: HTMLElement | ShadowRoot, id: string | undefined, element: HTMLElement): void => {
const label = id && docRoot.querySelector<HTMLElement>(`label[for='${id}']`);
const text = label && (label as HTMLElement).innerText;
if (text) {
element.setAttribute('aria-label', text);
}
};
const templates: TemplatesInterface = {
containerOuter(
{ classNames: { containerOuter } }: TemplateOptions,
dir: HTMLElement['dir'],
isSelectElement: boolean,
isSelectOneElement: boolean,
searchEnabled: boolean,
passedElementType: PassedElementType,
labelId: string,
): HTMLDivElement {
const div = document.createElement('div');
addClassesToElement(div, containerOuter);
div.dataset.type = passedElementType;
if (dir) {
div.dir = dir;
}
if (isSelectOneElement) {
div.tabIndex = 0;
}
if (isSelectElement) {
div.setAttribute('role', searchEnabled ? 'combobox' : 'listbox');
if (searchEnabled) {
div.setAttribute('aria-autocomplete', 'list');
} else if (!labelId) {
addAriaLabel(this._docRoot, this.passedElement.element.id, div);
}
div.setAttribute('aria-haspopup', 'true');
div.setAttribute('aria-expanded', 'false');
}
if (labelId) {
div.setAttribute('aria-labelledby', labelId);
}
return div;
},
containerInner({ classNames: { containerInner } }: TemplateOptions): HTMLDivElement {
const div = document.createElement('div');
addClassesToElement(div, containerInner);
return div;
},
itemList(
{ searchEnabled, classNames: { list, listSingle, listItems } }: TemplateOptions,
isSelectOneElement: boolean,
): HTMLDivElement {
const div = document.createElement('div');
addClassesToElement(div, list);
addClassesToElement(div, isSelectOneElement ? listSingle : listItems);
if (this._isSelectElement && searchEnabled) {
div.setAttribute('role', 'listbox');
}
return div;
},
placeholder(
{ allowHTML, classNames: { placeholder } }: TemplateOptions,
value: StringPreEscaped | string,
): HTMLDivElement {
const div = document.createElement('div');
addClassesToElement(div, placeholder);
setElementHtml(div, allowHTML, value);
return div;
},
item(
{
allowHTML,
removeItemButtonAlignLeft,
removeItemIconText,
removeItemLabelText,
classNames: { item, button, highlightedState, itemSelectable, placeholder },
}: TemplateOptions,
choice: ChoiceFull,
removeItemButton: boolean,
): HTMLDivElement {
const rawValue = unwrapStringForRaw(choice.value);
const div = document.createElement('div');
addClassesToElement(div, item);
if (choice.labelClass) {
const spanLabel = document.createElement('span');
setElementHtml(spanLabel, allowHTML, choice.label);
addClassesToElement(spanLabel, choice.labelClass);
div.appendChild(spanLabel);
} else {
setElementHtml(div, allowHTML, choice.label);
}
div.dataset.item = '';
div.dataset.id = choice.id as unknown as string;
div.dataset.value = rawValue;
assignCustomProperties(div, choice, true);
if (choice.disabled || this.containerOuter.isDisabled) {
div.setAttribute('aria-disabled', 'true');
}
if (this._isSelectElement) {
div.setAttribute('aria-selected', 'true');
div.setAttribute('role', 'option');
}
if (choice.placeholder) {
addClassesToElement(div, placeholder);
div.dataset.placeholder = '';
}
addClassesToElement(div, choice.highlighted ? highlightedState : itemSelectable);
if (removeItemButton) {
if (choice.disabled) {
removeClassesFromElement(div, itemSelectable);
}
div.dataset.deletable = '';
const removeButton = document.createElement('button');
removeButton.type = 'button';
addClassesToElement(removeButton, button);
setElementHtml(removeButton, true, resolveNoticeFunction(removeItemIconText, choice.value));
const REMOVE_ITEM_LABEL = resolveNoticeFunction(removeItemLabelText, choice.value);
if (REMOVE_ITEM_LABEL) {
removeButton.setAttribute('aria-label', REMOVE_ITEM_LABEL);
}
removeButton.dataset.button = '';
if (removeItemButtonAlignLeft) {
div.insertAdjacentElement('afterbegin', removeButton);
} else {
div.appendChild(removeButton);
}
}
return div;
},
choiceList({ classNames: { list } }: TemplateOptions, isSelectOneElement: boolean): HTMLDivElement {
const div = document.createElement('div');
addClassesToElement(div, list);
if (!isSelectOneElement) {
div.setAttribute('aria-multiselectable', 'true');
}
div.setAttribute('role', 'listbox');
return div;
},
choiceGroup(
{ allowHTML, classNames: { group, groupHeading, itemDisabled } }: TemplateOptions,
{ id, label, disabled }: GroupFull,
): HTMLDivElement {
const rawLabel = unwrapStringForRaw(label);
const div = document.createElement('div');
addClassesToElement(div, group);
if (disabled) {
addClassesToElement(div, itemDisabled);
}
div.setAttribute('role', 'group');
div.dataset.group = '';
div.dataset.id = id as unknown as string;
div.dataset.value = rawLabel;
if (disabled) {
div.setAttribute('aria-disabled', 'true');
}
const heading = document.createElement('div');
addClassesToElement(heading, groupHeading);
setElementHtml(heading, allowHTML, label || '');
div.appendChild(heading);
return div;
},
choice(
{
allowHTML,
classNames: { item, itemChoice, itemSelectable, selectedState, itemDisabled, description, placeholder },
}: TemplateOptions,
choice: ChoiceFull,
selectText: string,
groupName?: string,
): HTMLDivElement {
// eslint-disable-next-line prefer-destructuring
let label: string | StringUntrusted | StringPreEscaped = choice.label;
const rawValue = unwrapStringForRaw(choice.value);
const div = document.createElement('div');
div.id = choice.elementId as string;
addClassesToElement(div, item);
addClassesToElement(div, itemChoice);
if (groupName && typeof label === 'string') {
label = escapeForTemplate(allowHTML, label);
label += ` (${groupName})`;
label = { trusted: label };
}
let describedBy: HTMLElement = div;
if (choice.labelClass) {
const spanLabel = document.createElement('span');
setElementHtml(spanLabel, allowHTML, label);
addClassesToElement(spanLabel, choice.labelClass);
describedBy = spanLabel;
div.appendChild(spanLabel);
} else {
setElementHtml(div, allowHTML, label);
}
if (choice.labelDescription) {
const descId = `${choice.elementId}-description`;
describedBy.setAttribute('aria-describedby', descId);
const spanDesc = document.createElement('span');
setElementHtml(spanDesc, allowHTML, choice.labelDescription);
spanDesc.id = descId;
addClassesToElement(spanDesc, description);
div.appendChild(spanDesc);
}
if (choice.selected) {
addClassesToElement(div, selectedState);
}
if (choice.placeholder) {
addClassesToElement(div, placeholder);
}
div.setAttribute('role', choice.group ? 'treeitem' : 'option');
div.dataset.choice = '';
div.dataset.id = choice.id as unknown as string;
div.dataset.value = rawValue;
if (selectText) {
div.dataset.selectText = selectText;
}
if (choice.group) {
div.dataset.groupId = `${choice.group.id}`;
}
assignCustomProperties(div, choice, false);
if (choice.disabled) {
addClassesToElement(div, itemDisabled);
div.dataset.choiceDisabled = '';
div.setAttribute('aria-disabled', 'true');
} else {
addClassesToElement(div, itemSelectable);
div.dataset.choiceSelectable = '';
}
return div;
},
input(
{ classNames: { input, inputCloned }, labelId }: TemplateOptions,
placeholderValue: string | null,
): HTMLInputElement {
const inp = document.createElement('input');
inp.type = 'search';
addClassesToElement(inp, input);
addClassesToElement(inp, inputCloned);
inp.autocomplete = 'off';
inp.autocapitalize = 'off';
inp.spellcheck = false;
inp.setAttribute('aria-autocomplete', 'list');
if (placeholderValue) {
inp.setAttribute('aria-label', placeholderValue);
} else if (!labelId) {
addAriaLabel(this._docRoot, this.passedElement.element.id, inp);
}
return inp;
},
dropdown({ classNames: { list, listDropdown } }: TemplateOptions): HTMLDivElement {
const div = document.createElement('div');
addClassesToElement(div, list);
addClassesToElement(div, listDropdown);
div.setAttribute('aria-expanded', 'false');
return div;
},
notice(
{ classNames: { item, itemChoice, addChoice, noResults, noChoices, notice: noticeItem } }: TemplateOptions,
innerHTML: string,
type: NoticeType = NoticeTypes.generic,
): HTMLDivElement {
const notice = document.createElement('div');
setElementHtml(notice, true, innerHTML);
addClassesToElement(notice, item);
addClassesToElement(notice, itemChoice);
addClassesToElement(notice, noticeItem);
// eslint-disable-next-line default-case
switch (type) {
case NoticeTypes.addChoice:
addClassesToElement(notice, addChoice);
break;
case NoticeTypes.noResults:
addClassesToElement(notice, noResults);
break;
case NoticeTypes.noChoices:
addClassesToElement(notice, noChoices);
break;
}
if (type === NoticeTypes.addChoice) {
notice.dataset.choiceSelectable = '';
notice.dataset.choice = '';
}
return notice;
},
option(choice: ChoiceFull): HTMLOptionElement {
// HtmlOptionElement's label value does not support HTML, so the avoid double escaping unwrap the untrusted string.
const labelValue = unwrapStringForRaw(choice.label);
const opt = new Option(labelValue, choice.value, false, choice.selected);
assignCustomProperties(opt, choice, true);
opt.disabled = choice.disabled;
if (choice.selected) {
opt.setAttribute('selected', '');
}
return opt;
},
};
export default templates;