Further type additions

This commit is contained in:
Josh Johnson 2019-12-14 16:08:15 +00:00
parent 271d18662b
commit 585ee6457f
8 changed files with 198 additions and 109 deletions

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Fuse from 'fuse.js';
import merge from 'deepmerge';
@ -43,7 +44,6 @@ import {
import {
Templates,
Options,
PassedElement,
Choice,
Item,
Group,
@ -51,6 +51,7 @@ import {
KeyDownAction,
State,
} from './interfaces';
import { defaultState } from './reducers';
/** @see {@link http://browserhacks.com/#hack-acea075d0ac6954f275a70023906050c} */
const IS_IE11 =
@ -64,7 +65,10 @@ const USER_DEFAULTS: Partial<Options> = {};
* @author Josh Johnson<josh@joshuajohnson.co.uk>
*/
class Choices {
static get defaults() {
static get defaults(): {
options: Partial<Options>;
templates: Templates;
} {
return Object.preventExtensions({
get options(): Partial<Options> {
return USER_DEFAULTS;
@ -76,14 +80,21 @@ class Choices {
}
initialised: boolean;
config: Partial<Options>;
passedElement: PassedElement;
config: Options;
passedElement: WrappedInput | WrappedSelect;
containerOuter: Container;
containerInner: Container;
choiceList: List;
itemList: List;
input: Input;
dropdown: Dropdown;
_isTextElement: boolean;
_isSelectOneElement: boolean;
_isSelectMultipleElement: boolean;
_isSelectElement: boolean;
_store: Store;
_templates: Templates;
_initialState: State;
_currentState: State;
_prevState: State;
@ -93,22 +104,22 @@ class Choices {
_highlightPosition: number;
_wasTap: boolean;
_isSearching: boolean;
_placeholderValue: string;
_placeholderValue: string | null;
_baseId: string;
_direction: 'ltr' | 'rtl';
_direction: HTMLElement['dir'];
_idNames: {
itemChoice: string;
};
_presetGroups: HTMLOptGroupElement[];
_presetOptions: HTMLOptionElement[];
_presetChoices: Choice[];
_presetItems: Item[];
_presetChoices: Partial<Choice>[];
_presetItems: Item[] | string[];
constructor(
element: string | HTMLInputElement | HTMLSelectElement = '[data-choice]',
userConfig: Partial<Options> = {},
) {
this.config = merge.all(
this.config = merge.all<Options>(
[DEFAULT_CONFIG, Choices.defaults.options, userConfig],
// When merging array configs, replace with a copy of the userConfig array,
// instead of concatenating with the default array
@ -164,35 +175,36 @@ class Choices {
if (this._isTextElement) {
this.passedElement = new WrappedInput({
element: passedElement,
element: passedElement as HTMLInputElement,
classNames: this.config.classNames,
delimiter: this.config.delimiter,
});
} else {
this.passedElement = new WrappedSelect({
element: passedElement,
element: passedElement as HTMLSelectElement,
classNames: this.config.classNames,
template: data => this._templates.option(data),
template: (data: object): HTMLOptionElement =>
this._templates.option(data),
});
}
this.initialised = false;
this._store = new Store();
this._initialState = {};
this._currentState = {};
this._prevState = {};
this._initialState = defaultState;
this._currentState = defaultState;
this._prevState = defaultState;
this._currentValue = '';
this._canSearch = this.config.searchEnabled;
this._canSearch = !!this.config.searchEnabled;
this._isScrollingOnIe = false;
this._highlightPosition = 0;
this._wasTap = true;
this._placeholderValue = this._generatePlaceholderValue();
this._baseId = generateId(this.passedElement.element, 'choices-');
/**
* setting direction in cases where it's explicitly set on passedElement
* or when calculated direction is different from the document
* @type {HTMLElement['dir']}
*/
this._direction = this.passedElement.dir;
@ -211,10 +223,14 @@ class Choices {
this._idNames = {
itemChoice: 'item-choice',
};
// Assign preset groups from passed element
this._presetGroups = this.passedElement.optionGroups;
// Assign preset options from passed element
this._presetOptions = this.passedElement.options;
if (this._isSelectElement) {
// Assign preset groups from passed element
this._presetGroups = this.passedElement.optionGroups;
// Assign preset options from passed element
this._presetOptions = this.passedElement.options;
}
// Assign preset choices from passed object
this._presetChoices = this.config.choices;
// Assign preset items from passed object first
@ -231,8 +247,8 @@ class Choices {
this._presetChoices.push({
value: o.value,
label: o.innerHTML,
selected: o.selected,
disabled: o.disabled || o.parentNode.disabled,
selected: !!o.selected,
disabled: o.parentNode ? !!o.parentNode.disabled : !!o.disabled,
placeholder: o.value === '' || o.hasAttribute('placeholder'),
customProperties: o.getAttribute('data-custom-properties'),
});
@ -322,7 +338,7 @@ class Choices {
this.passedElement.options = this._presetOptions;
}
this._templates = null;
this._templates = TEMPLATES;
this.initialised = false;
}
@ -436,7 +452,7 @@ class Choices {
return this;
}
showDropdown(preventInputFocus: boolean): this {
showDropdown(preventInputFocus?: boolean): this {
if (this.dropdown.isActive) {
return this;
}
@ -455,7 +471,7 @@ class Choices {
return this;
}
hideDropdown(preventInputBlur: boolean): this {
hideDropdown(preventInputBlur?: boolean): this {
if (!this.dropdown.isActive) {
return this;
}
@ -519,13 +535,6 @@ class Choices {
*
* **Input types affected:** select-one, select-multiple
*
* @template {Choice[] | ((instance: Choices) => object[] | Promise<object[]>)} T
* @param {T} [choicesArrayOrFetcher]
* @param {string} [value = 'value'] - name of `value` field
* @param {string} [label = 'label'] - name of 'label' field
* @param {boolean} [replaceChoices = false] - whether to replace of add choices
* @returns {this | Promise<this>}
*
* @example
* ```js
* const example = new Choices(element);
@ -653,7 +662,7 @@ class Choices {
choicesArrayOrFetcher.forEach(groupOrChoice => {
if (groupOrChoice.choices) {
this._addGroup({
id: parseInt(groupOrChoice.id, 10) || null,
id: groupOrChoice.id ? parseInt(`${groupOrChoice.id}`, 10) : null,
group: groupOrChoice,
valueKey: value,
labelKey: label,
@ -662,10 +671,10 @@ class Choices {
this._addChoice({
value: groupOrChoice[value],
label: groupOrChoice[label],
isSelected: groupOrChoice.selected,
isDisabled: groupOrChoice.disabled,
isSelected: !!groupOrChoice.selected,
isDisabled: !!groupOrChoice.disabled,
placeholder: !!groupOrChoice.placeholder,
customProperties: groupOrChoice.customProperties,
placeholder: groupOrChoice.placeholder,
});
}
});
@ -778,8 +787,8 @@ class Choices {
this.choiceList.append(choiceListFragment);
this._highlightChoice();
} else {
// ...otherwise show a notice
this.choiceList.append(this._getTemplate('notice', canAddItem.notice));
const notice = this._getTemplate('notice', canAddItem.notice);
this.choiceList.append(notice);
}
} else {
// Otherwise show a notice
@ -825,7 +834,7 @@ class Choices {
choices: Choice[],
fragment: DocumentFragment = document.createDocumentFragment(),
): DocumentFragment {
const getGroupChoices = group =>
const getGroupChoices = (group): Choice[] =>
choices.filter(choice => {
if (this._isSelectOneElement) {
return choice.groupId === group.id;
@ -866,17 +875,19 @@ class Choices {
renderChoiceLimit,
} = this.config;
const filter = this._isSearching ? sortByScore : this.config.sorter;
const appendChoice = choice => {
const appendChoice = (choice): void => {
const shouldRender =
renderSelectedChoices === 'auto'
? this._isSelectOneElement || !choice.selected
: true;
if (shouldRender) {
const dropdownItem = this._getTemplate(
'choice',
choice,
this.config.itemSelectText,
);
fragment.appendChild(dropdownItem);
}
};
@ -949,7 +960,7 @@ class Choices {
this.passedElement.options = items;
}
const addItemToFragment = item => {
const addItemToFragment = (item: Item): void => {
// Create new list element
const listItem = this._getTemplate('item', item, removeItemButton);
// Append it to list
@ -988,7 +999,7 @@ class Choices {
}
}
_handleButtonAction(activeItems: Item[], element: HTMLElement): void {
_handleButtonAction(activeItems?: Item[], element?: HTMLElement): void {
if (
!activeItems ||
!element ||
@ -998,10 +1009,16 @@ class Choices {
return;
}
const itemId = element.parentNode.getAttribute('data-id');
const itemToRemove = activeItems.find(
item => item.id === parseInt(itemId, 10),
);
const itemId =
element.parentNode &&
(element.parentNode as HTMLElement).dataset['data-id'];
const itemToRemove =
itemId && activeItems.find(item => item.id === parseInt(itemId, 10));
if (!itemToRemove) {
return;
}
// Remove item associated with button
this._removeItem(itemToRemove);
@ -1013,8 +1030,8 @@ class Choices {
}
_handleItemAction(
activeItems: Item[],
element: HTMLElement,
activeItems?: Item[],
element?: HTMLElement,
hasShiftKey = false,
): void {
if (
@ -1032,7 +1049,7 @@ class Choices {
// so we deselect any items that aren't the target
// unless shift is being pressed
activeItems.forEach(item => {
if (item.id === parseInt(passedId, 10) && !item.highlighted) {
if (item.id === parseInt(`${passedId}`, 10) && !item.highlighted) {
this.highlightItem(item);
} else if (!hasShiftKey && item.highlighted) {
this.unhighlightItem(item);
@ -1044,19 +1061,22 @@ class Choices {
this.input.focus();
}
_handleChoiceAction(activeItems: Item[], element: HTMLElement): void {
_handleChoiceAction(activeItems?: Item[], element?: HTMLElement): void {
if (!activeItems || !element) {
return;
}
// If we are clicking on an option
const { id } = element.dataset;
const choice = this._store.getChoiceById(id);
const choice = id && this._store.getChoiceById(id);
if (!choice) {
return;
}
const passedKeyCode =
activeItems[0] && activeItems[0].keyCode ? activeItems[0].keyCode : null;
activeItems[0] && activeItems[0].keyCode
? activeItems[0].keyCode
: undefined;
const hasActiveDropdown = this.dropdown.isActive;
// Update choice keyCode
@ -1093,7 +1113,7 @@ class Choices {
}
}
_handleBackspace(activeItems: Item[]): void {
_handleBackspace(activeItems?: Item[]): void {
if (!this.config.removeItems || !activeItems) {
return;
}
@ -1140,7 +1160,10 @@ class Choices {
'placeholder',
this.config.loadingText,
);
this.itemList.append(placeholderItem);
if (placeholderItem) {
this.itemList.append(placeholderItem);
}
} else {
placeholderItem.innerHTML = this.config.loadingText;
}
@ -1152,7 +1175,9 @@ class Choices {
this.containerOuter.removeLoadingState();
if (this._isSelectOneElement) {
placeholderItem.innerHTML = this._placeholderValue || '';
if (placeholderItem) {
placeholderItem.innerHTML = this._placeholderValue || '';
}
} else {
this.input.placeholder = this._placeholderValue || '';
}
@ -1433,7 +1458,7 @@ class Choices {
}
} else {
const wasRemovalKeyCode = keyCode === backKey || keyCode === deleteKey;
const userHasRemovedValue = wasRemovalKeyCode && !target.value;
const userHasRemovedValue = wasRemovalKeyCode && target && !target.value;
const canReactivateChoices = !this._isTextElement && this._isSearching;
const canSearch = this._canSearch && canAddItem.response;
@ -1449,6 +1474,10 @@ class Choices {
}
_onAKey({ event, hasItems }: Partial<KeyDownAction>): void {
if (!event || event.type !== 'KeyboardEvent') {
return;
}
const { ctrlKey, metaKey } = event;
const hasCtrlDownKeyPressed = ctrlKey || metaKey;
@ -1471,12 +1500,12 @@ class Choices {
event,
activeItems,
hasActiveDropdown,
}: Partial<KeyDownAction>): void {
}: Pick<KeyDownAction, 'event' | 'activeItems' | 'hasActiveDropdown'>): void {
const { target } = event;
const { ENTER_KEY: enterKey } = KEY_CODES;
const targetWasButton = target.hasAttribute('data-button');
const targetWasButton = target && target.hasAttribute('data-button');
if (this._isTextElement && target.value) {
if (this._isTextElement && target && target.value) {
const { value } = this.input;
const canAddItem = this._canAddItem(activeItems, value);
@ -1716,27 +1745,26 @@ class Choices {
}
_onFocus({ target }: Partial<FocusEvent>): void {
const focusWasWithinContainer = this.containerOuter.element.contains(
target,
);
const focusWasWithinContainer =
target && this.containerOuter.element.contains(target as Node);
if (!focusWasWithinContainer) {
return;
}
const focusActions = {
[TEXT_TYPE]: () => {
[TEXT_TYPE]: (): void => {
if (target === this.input.element) {
this.containerOuter.addFocusState();
}
},
[SELECT_ONE_TYPE]: () => {
[SELECT_ONE_TYPE]: (): void => {
this.containerOuter.addFocusState();
if (target === this.input.element) {
this.showDropdown(true);
}
},
[SELECT_MULTIPLE_TYPE]: () => {
[SELECT_MULTIPLE_TYPE]: (): void => {
if (target === this.input.element) {
this.showDropdown(true);
// If element is a select box, the focused element is the container and the dropdown
@ -1749,14 +1777,15 @@ class Choices {
focusActions[this.passedElement.element.type]();
}
_onBlur({ target }: Partial<FocusEvent>): void {
const blurWasWithinContainer = this.containerOuter.element.contains(target);
_onBlur({ target }: Pick<FocusEvent, 'target'>): void {
const blurWasWithinContainer =
target && this.containerOuter.element.contains(target as Node);
if (blurWasWithinContainer && !this._isScrollingOnIe) {
const { activeItems } = this._store;
const hasHighlightedItems = activeItems.some(item => item.highlighted);
const blurActions = {
[TEXT_TYPE]: () => {
[TEXT_TYPE]: (): void => {
if (target === this.input.element) {
this.containerOuter.removeFocusState();
if (hasHighlightedItems) {
@ -1765,7 +1794,7 @@ class Choices {
this.hideDropdown(true);
}
},
[SELECT_ONE_TYPE]: () => {
[SELECT_ONE_TYPE]: (): void => {
this.containerOuter.removeFocusState();
if (
target === this.input.element ||
@ -1774,7 +1803,7 @@ class Choices {
this.hideDropdown(true);
}
},
[SELECT_MULTIPLE_TYPE]: () => {
[SELECT_MULTIPLE_TYPE]: (): void => {
if (target === this.input.element) {
this.containerOuter.removeFocusState();
this.hideDropdown(true);
@ -1858,7 +1887,15 @@ class Choices {
customProperties = null,
placeholder = false,
keyCode = null,
}: Item): void {
}: {
value: string;
label?: string | null;
choiceId?: number;
groupId?: number;
customProperties?: object | null;
placeholder?: boolean;
keyCode?: number | null;
}): void {
let passedValue = typeof value === 'string' ? value.trim() : value;
const passedKeyCode = keyCode;
@ -1909,7 +1946,8 @@ class Choices {
_removeItem(item: Item): void {
const { id, value, label, customProperties, choiceId, groupId } = item;
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
const group =
groupId && groupId >= 0 ? this._store.getGroupById(groupId) : null;
this._store.dispatch(removeItem(id, choiceId));
this.passedElement.triggerEvent(EVENTS.removeItem, {
@ -1930,7 +1968,16 @@ class Choices {
customProperties = null,
placeholder = false,
keyCode = null,
}: Choice): void {
}: {
value: string;
label?: string | null;
isSelected?: boolean;
isDisabled?: boolean;
groupId?: number;
customProperties?: Record<string, any> | null;
placeholder?: boolean;
keyCode?: number | null;
}): void {
if (typeof value === 'undefined' || value === null) {
return;
}
@ -2012,14 +2059,7 @@ class Choices {
}
}
_getTemplate<K extends keyof Templates>(
template: K,
...args
): Templates[K] | null {
if (!template) {
return null;
}
_getTemplate<K extends keyof Templates>(template: K, ...args): Templates[K] {
const { classNames } = this.config;
return this._templates[template].call(this, classNames, ...args);
@ -2134,7 +2174,7 @@ class Choices {
}
}
_addPredefinedGroups(groups: Group[]): void {
_addPredefinedGroups(groups: Group[] | HTMLOptGroupElement[]): void {
// If we have a placeholder option
const placeholderChoice = this.passedElement.placeholderOption;
if (
@ -2197,26 +2237,26 @@ class Choices {
this._addChoice({
value,
label,
isSelected,
isDisabled,
isSelected: !!isSelected,
isDisabled: !!isDisabled,
placeholder: !!placeholder,
customProperties,
placeholder,
});
}
} else {
this._addChoice({
value,
label,
isSelected: choice.selected,
isDisabled: choice.disabled,
isSelected: !!choice.selected,
isDisabled: !!choice.disabled,
placeholder: !!choice.placeholder,
customProperties,
placeholder,
});
}
});
}
_addPredefinedItems(items: Item[]): void {
_addPredefinedItems(items: Item[] | string[]): void {
items.forEach(item => {
if (typeof item === 'object' && item.value) {
this._addItem({
@ -2304,7 +2344,7 @@ class Choices {
}
}
_generatePlaceholderValue(): string | boolean {
_generatePlaceholderValue(): string | null {
if (this._isSelectElement) {
const { placeholderOption } = this.passedElement;
@ -2326,7 +2366,7 @@ class Choices {
}
}
return false;
return null;
}
}

View file

@ -133,7 +133,7 @@ export default class Container {
this.isDisabled = true;
}
wrap(element: HTMLElement): void {
wrap(element: HTMLSelectElement | HTMLInputElement | HTMLElement): void {
wrap(element, this.element);
}

View file

@ -26,7 +26,7 @@ export default class Input {
this.classNames = classNames;
this.preventPaste = preventPaste;
this.isFocussed = this.element === document.activeElement;
this.isFocussed = this.element.isEqualNode(document.activeElement);
this.isDisabled = element.disabled;
this._onPaste = this._onPaste.bind(this);
this._onInput = this._onInput.bind(this);

View file

@ -1,16 +1,24 @@
import WrappedElement from './wrapped-element';
import { ClassNames } from '../interfaces';
import { ClassNames, Item } from '../interfaces';
export default class WrappedInput extends WrappedElement {
element: HTMLInputElement;
delimiter: string;
constructor({ element, classNames, delimiter }) {
constructor({
element,
classNames,
delimiter,
}: {
element: HTMLInputElement;
classNames: ClassNames;
delimiter: string;
}) {
super({ element, classNames });
this.delimiter = delimiter;
}
get value(): string {
get value(): Item['value'] {
return this.element.value;
}

View file

@ -2,7 +2,7 @@ import WrappedElement from './wrapped-element';
import { ClassNames, Item, Choice } from '../interfaces';
export default class WrappedSelect extends WrappedElement {
element: HTMLInputElement;
element: HTMLSelectElement;
classNames: ClassNames;
template: () => HTMLElement;
@ -11,7 +11,7 @@ export default class WrappedSelect extends WrappedElement {
classNames,
template,
}: {
element: HTMLInputElement;
element: HTMLSelectElement;
classNames: ClassNames;
template: () => HTMLElement;
}) {

View file

@ -1,5 +1,11 @@
import { sanitise, sortByAlpha } from './lib/utils';
import { Options, ClassNames } from './interfaces';
import {
Options,
ClassNames,
EventMap,
ActionType,
KeyCodeMap,
} from './interfaces';
export const DEFAULT_CLASSNAMES: ClassNames = {
containerOuter: 'choices',
@ -77,7 +83,7 @@ export const DEFAULT_CONFIG: Options = {
classNames: DEFAULT_CLASSNAMES,
};
export const EVENTS = {
export const EVENTS: Record<keyof EventMap, keyof EventMap> = {
showDropdown: 'showDropdown',
hideDropdown: 'hideDropdown',
change: 'change',
@ -87,9 +93,10 @@ export const EVENTS = {
removeItem: 'removeItem',
highlightItem: 'highlightItem',
highlightChoice: 'highlightChoice',
unhighlightItem: 'unhighlightItem',
};
export const ACTION_TYPES = {
export const ACTION_TYPES: Record<ActionType, ActionType> = {
ADD_CHOICE: 'ADD_CHOICE',
FILTER_CHOICES: 'FILTER_CHOICES',
ACTIVATE_CHOICES: 'ACTIVATE_CHOICES',
@ -101,7 +108,7 @@ export const ACTION_TYPES = {
CLEAR_ALL: 'CLEAR_ALL',
};
export const KEY_CODES = {
export const KEY_CODES: KeyCodeMap = {
BACK_KEY: 46,
DELETE_KEY: 8,
ENTER_KEY: 13,
@ -113,8 +120,9 @@ export const KEY_CODES = {
PAGE_DOWN_KEY: 34,
};
export const TEXT_TYPE = 'text';
export const SELECT_ONE_TYPE = 'select-one';
export const SELECT_MULTIPLE_TYPE = 'select-multiple';
export const TEXT_TYPE: HTMLInputElement['type'] = 'text';
export const SELECT_ONE_TYPE: HTMLSelectElement['type'] = 'select-one';
export const SELECT_MULTIPLE_TYPE: HTMLSelectElement['type'] =
'select-multiple';
export const SCROLLING_SPEED = 4;

View file

@ -15,6 +15,7 @@ export namespace Types {
}
export interface Choice {
choices: any;
id?: number;
customProperties?: Record<string, any>;
disabled?: boolean;
@ -155,6 +156,29 @@ export interface EventMap {
highlightChoice: CustomEvent<{ el: PassedElement }>;
}
export interface KeyCodeMap {
BACK_KEY: 46;
DELETE_KEY: 8;
ENTER_KEY: 13;
A_KEY: 65;
ESC_KEY: 27;
UP_KEY: 38;
DOWN_KEY: 40;
PAGE_UP_KEY: 33;
PAGE_DOWN_KEY: 34;
}
export type ActionType =
| 'ADD_CHOICE'
| 'FILTER_CHOICES'
| 'ACTIVATE_CHOICES'
| 'CLEAR_CHOICES'
| 'ADD_GROUP'
| 'ADD_ITEM'
| 'REMOVE_ITEM'
| 'HIGHLIGHT_ITEM'
| 'CLEAR_ALL';
export interface Templates {
containerOuter: (
this: Choices,
@ -210,7 +234,7 @@ export interface Templates {
label: string,
type: '' | 'no-results' | 'no-choices',
) => HTMLElement;
option: (data: Choice) => HTMLOptionElement;
option: (data: object) => HTMLOptionElement;
}
/** Classes added to HTML generated by By default classnames follow the BEM notation. */
@ -269,7 +293,7 @@ export interface ClassNames {
noChoices: string;
}
export interface PassedElement {
export interface PassedElement extends HTMLElement {
classNames: ClassNames;
element: (HTMLInputElement | HTMLSelectElement) & {
// Extends HTMLElement addEventListener with Choices events

View file

@ -5,6 +5,15 @@ import choices from './choices';
import general from './general';
import { cloneObject } from '../lib/utils';
export const defaultState = {
groups: [],
items: [],
choices: [],
general: {
loading: false,
},
};
const appReducer = combineReducers({
items,
groups,
@ -19,7 +28,7 @@ const rootReducer = (passedState, action) => {
// mutating our actual state
// See: http://stackoverflow.com/a/35641992
if (action.type === 'CLEAR_ALL') {
state = undefined;
state = defaultState;
} else if (action.type === 'RESET_TO') {
return cloneObject(action.state);
}