slim-select
Advanced tools
Comparing version 1.27.1 to 2.0.0
{ | ||
"editor.formatOnSave": false, | ||
"extensions.ignoreRecommendations": false, | ||
"editor.detectIndentation": false, | ||
"editor.formatOnSave": true, | ||
"editor.insertSpaces": true, | ||
"editor.tabSize": 2, | ||
"tslint.autoFixOnSave": true | ||
} | ||
"editor.defaultFormatter": "esbenp.prettier-vscode", | ||
"[javascript]": { | ||
"editor.defaultFormatter": "esbenp.prettier-vscode" | ||
}, | ||
"[typescript]": { | ||
"editor.defaultFormatter": "esbenp.prettier-vscode" | ||
}, | ||
"editor.codeActionsOnSave": { | ||
"source.fixAll": true, | ||
"source.organizeImports": false, | ||
"source.sortMembers": true | ||
} | ||
} |
@@ -0,7 +1,4 @@ | ||
export declare function generateID(): string; | ||
export declare function hasClassInTree(element: HTMLElement, className: string): any; | ||
export declare function ensureElementInView(container: HTMLElement, element: HTMLElement): void; | ||
export declare function putContent(el: HTMLElement, currentPosition: string, isOpen: boolean): string; | ||
export declare function debounce(func: (...params: any[]) => void, wait?: number, immediate?: boolean): () => void; | ||
export declare function isValueInArrayOfObjects(selected: any, key: string, value: string): boolean; | ||
export declare function highlight(str: string, search: any, className: string): any; | ||
export declare function kebabCase(str: string): string; |
@@ -1,33 +0,23 @@ | ||
import { Config } from './config'; | ||
import { Select } from './select'; | ||
import { Slim } from './slim'; | ||
import { Data, dataArray, Option } from './data'; | ||
interface Constructor { | ||
import Render from './render'; | ||
import Select from './select'; | ||
import Settings, { SettingsPartial } from './settings'; | ||
import Store, { DataArray, DataArrayPartial, Option, OptionOptional } from './store'; | ||
export * from './helper'; | ||
export * from './settings'; | ||
export * from './select'; | ||
export * from './store'; | ||
export * from './render'; | ||
export { Settings, Select, Store, Render }; | ||
export interface Config { | ||
select: string | Element; | ||
data?: dataArray; | ||
showSearch?: boolean; | ||
searchPlaceholder?: string; | ||
searchText?: string; | ||
searchingText?: string; | ||
searchFocus?: boolean; | ||
searchHighlight?: boolean; | ||
searchFilter?: (opt: Option, search: string) => boolean; | ||
closeOnSelect?: boolean; | ||
showContent?: string; | ||
placeholder?: string; | ||
allowDeselect?: boolean; | ||
allowDeselectOption?: boolean; | ||
hideSelectedOption?: boolean; | ||
deselectLabel?: string; | ||
isEnabled?: boolean; | ||
valuesUseText?: boolean; | ||
showOptionTooltips?: boolean; | ||
selectByGroup?: boolean; | ||
limit?: number; | ||
timeoutDelay?: number; | ||
addToBody?: boolean; | ||
ajax?: (value: string, func: (info: any) => void) => void; | ||
addable?: (value: string) => Option | string; | ||
beforeOnChange?: (info: Option | Option[]) => void | boolean; | ||
onChange?: (info: Option | Option[]) => void; | ||
data?: DataArrayPartial; | ||
settings?: SettingsPartial; | ||
events?: Events; | ||
} | ||
export interface Events { | ||
search?: (searchValue: string, currentData: DataArray) => Promise<DataArrayPartial> | DataArrayPartial; | ||
searchFilter?: (option: Option, search: string) => boolean; | ||
addable?: (value: string) => OptionOptional | string; | ||
beforeChange?: (newVal: Option[], oldVal: Option[]) => boolean | void; | ||
afterChange?: (newVal: Option[]) => void; | ||
beforeOpen?: () => void; | ||
@@ -37,36 +27,26 @@ afterOpen?: () => void; | ||
afterClose?: () => void; | ||
error?: (err: Error) => void; | ||
} | ||
export default class SlimSelect { | ||
config: Config; | ||
selectEl: HTMLSelectElement; | ||
settings: Settings; | ||
select: Select; | ||
data: Data; | ||
slim: Slim; | ||
ajax: ((value: string, func: (info: any) => void) => void) | null; | ||
addable: ((value: string) => Option | string) | null; | ||
beforeOnChange: ((info: Option) => void | boolean) | null; | ||
onChange: ((info: Option) => void) | null; | ||
beforeOpen: (() => void) | null; | ||
afterOpen: (() => void) | null; | ||
beforeClose: (() => void) | null; | ||
afterClose: (() => void) | null; | ||
private windowScroll; | ||
constructor(info: Constructor); | ||
validate(info: Constructor): HTMLSelectElement; | ||
selected(): string | string[]; | ||
set(value: string | string[], type?: string, close?: boolean, render?: boolean): void; | ||
setSelected(value: string | string[], type?: string, close?: boolean, render?: boolean): void; | ||
setData(data: dataArray): void; | ||
addData(data: Option): void; | ||
store: Store; | ||
render: Render; | ||
events: Events; | ||
constructor(config: Config); | ||
enable(): void; | ||
disable(): void; | ||
getData(): DataArray; | ||
setData(data: DataArrayPartial): void; | ||
getSelected(): string[]; | ||
setSelected(value: string | string[]): void; | ||
addOption(option: OptionOptional): void; | ||
open(): void; | ||
close(): void; | ||
moveContentAbove(): void; | ||
moveContentBelow(): void; | ||
enable(): void; | ||
disable(): void; | ||
search(value: string): void; | ||
setSearchText(text: string): void; | ||
render(): void; | ||
destroy(id?: string | null): void; | ||
destroy(): void; | ||
private windowResize; | ||
private windowScroll; | ||
private documentClick; | ||
} | ||
export {}; |
@@ -1,22 +0,33 @@ | ||
import SlimSelect from './index'; | ||
import { dataArray } from './data'; | ||
interface Constructor { | ||
import { DataArray, Optgroup, Option } from './store'; | ||
export default class Select { | ||
select: HTMLSelectElement; | ||
main: SlimSelect; | ||
listen: boolean; | ||
onSelectChange?: (data: DataArray) => void; | ||
onValueChange?: (value: string[]) => void; | ||
private observer; | ||
constructor(select: HTMLSelectElement); | ||
enable(): void; | ||
disable(): void; | ||
hideUI(): void; | ||
showUI(): void; | ||
changeListen(on: boolean): void; | ||
addSelectChangeListener(func: (data: DataArray) => void): void; | ||
removeSelectChangeListener(): void; | ||
addValueChangeListener(func: (value: string[]) => void): void; | ||
removeValueChangeListener(): void; | ||
valueChange(ev: Event): any; | ||
private observeWrapper; | ||
private addObserver; | ||
private connectObserver; | ||
private disconnectObserver; | ||
getData(): DataArray; | ||
getDataFromOptgroup(optgroup: HTMLOptGroupElement): Optgroup; | ||
getSelectedValues(): string[]; | ||
getDataFromOption(option: HTMLOptionElement): Option; | ||
setSelected(value: string[]): void; | ||
updateSelect(id?: string, style?: string, classes?: string[]): void; | ||
updateOptions(data: DataArray): void; | ||
createOptgroup(optgroup: Optgroup): HTMLOptGroupElement; | ||
createOption(info: Option): HTMLOptionElement; | ||
destroy(): void; | ||
} | ||
export declare class Select { | ||
element: HTMLSelectElement; | ||
main: SlimSelect; | ||
mutationObserver: MutationObserver | null; | ||
triggerMutationObserver: boolean; | ||
constructor(info: Constructor); | ||
setValue(): void; | ||
addAttributes(): void; | ||
addEventListeners(): void; | ||
addMutationObserver(): void; | ||
observeMutationObserver(): void; | ||
disconnectMutationObserver(): void; | ||
create(data: dataArray): void; | ||
createOption(info: any): HTMLOptionElement; | ||
} | ||
export {}; |
{ | ||
"name": "slim-select", | ||
"description": "Slim advanced select dropdown", | ||
"version": "1.27.1", | ||
"version": "2.0.0", | ||
"author": "Brian Voelker <brian@webiswhatido.com> (http://webiswhatido.com)", | ||
@@ -11,13 +11,9 @@ "homepage": "https://slimselectjs.com", | ||
}, | ||
"engines": { | ||
"node": ">=8" | ||
}, | ||
"main": "dist/slimselect.min.js", | ||
"exports": { | ||
"require": "./dist/slimselect.min.js", | ||
"import": "./dist/slimselect.min.mjs" | ||
}, | ||
"style": "dist/slimselect.min.css", | ||
"main": "dist/slimselect.js", | ||
"module": "dist/slimselect.es.js", | ||
"unpkg": "dist/slimselect.js", | ||
"types": "dist/index.d.ts", | ||
"typings": "dist/index.d.ts", | ||
"style": "dist/slimselect.css", | ||
"sass": "src/slim-select/slimselect.scss", | ||
"types": "dist/index.d.ts", | ||
"repository": { | ||
@@ -35,29 +31,37 @@ "type": "git", | ||
"scripts": { | ||
"dev": "vue-cli-service serve", | ||
"library": "rm -r dist && cd src/slim-select && webpack && cd ../../ && npm run cleanDist && npm run renameDist && npm run mjs", | ||
"cleanDist": "rm dist/slimselectcss.min.js && rm dist/slimselectcss.js", | ||
"renameDist": "mv 'dist/slimselectcss.css' 'dist/slimselect.css' && mv 'dist/slimselectcss.min.css' 'dist/slimselect.min.css'", | ||
"docs": "vue-cli-service build", | ||
"build": "npm run docs && npm run library", | ||
"mjs": "(printf 'var exports = {};'; cat dist/slimselect.min.js; printf 'export default exports.SlimSelect') > dist/slimselect.min.mjs", | ||
"lint": "vue-cli-service lint" | ||
"jestinit": "ts-jest config:init", | ||
"dev": "vite --port=1111", | ||
"format": "prettier --write --cache --parser typescript \"src/**/*.ts\"", | ||
"build": "npm run build:clean && npm run build:docs && npm run build:library", | ||
"build:clean": "rimraf ./dist/*", | ||
"build:docs": "vite build", | ||
"build:library": "npm run build:library:js && npm run build:library:css", | ||
"build:library:js": "cd src/slim-select && rollup --config ./rollup.config.mjs && cd ../../", | ||
"build:library:css": "cd src/slim-select && sass ./slimselect.scss ../../dist/slimselect.css --style=compressed && cd ../../", | ||
"test": "jest" | ||
}, | ||
"devDependencies": { | ||
"@vue/cli-plugin-babel": "^4.5.15", | ||
"@vue/cli-plugin-typescript": "^4.5.15", | ||
"@vue/cli-service": "^4.5.15", | ||
"chance": "^1.1.8", | ||
"clipboard": "^2.0.8", | ||
"node-sass": "^5.0.0", | ||
"optimize-css-assets-webpack-plugin": "^5.0.4", | ||
"prismjs": "^1.25.0", | ||
"sass-loader": "^10.1.0", | ||
"typescript": "^4.5.4", | ||
"uglifyjs-webpack-plugin": "^2.2.0", | ||
"vue": "^2.6.14", | ||
"vue-router": "^3.5.3", | ||
"vue-template-compiler": "^2.6.14", | ||
"vuex": "^3.6.2", | ||
"webpack-cli": "^4.9.1" | ||
"@jest/globals": "^29.3.1", | ||
"@rollup/plugin-typescript": "^9.0.2", | ||
"@types/downloadjs": "^1.4.3", | ||
"@vitejs/plugin-vue": "^3.2.0", | ||
"clipboard": "^2.0.11", | ||
"downloadjs": "^1.4.7", | ||
"jest": "^29.3.1", | ||
"jest-environment-jsdom": "^29.3.1", | ||
"prettier": "^2.7.1", | ||
"prismjs": "^1.29.0", | ||
"rimraf": "^3.0.2", | ||
"rollup": "^2.79.1", | ||
"rollup-plugin-terser": "^7.0.2", | ||
"sass": "^1.56.1", | ||
"ts-jest": "^29.0.3", | ||
"tslib": "^2.4.1", | ||
"typescript": "^4.9.3", | ||
"vite": "^3.2.4", | ||
"vue": "^3.2.45", | ||
"vue-router": "^4.1.6", | ||
"vue-tsc": "^1.0.9", | ||
"vuex": "^4.0.2" | ||
} | ||
} |
@@ -16,3 +16,4 @@ # Slim Select | ||
- No Dependencies | ||
- 20kb - 5kb gzip | ||
- JS: 35.4kb - 9kb gzip | ||
- CSS: 6.09kb - 1kb gzip | ||
- Single Select | ||
@@ -19,0 +20,0 @@ - Multi Select |
@@ -1,17 +0,45 @@ | ||
import Vue from 'vue' | ||
import Router from 'vue-router' | ||
import { createRouter, createWebHistory } from 'vue-router' | ||
Vue.use(Router) | ||
export default new Router({ | ||
mode: 'history', | ||
base: '/', | ||
const router = createRouter({ | ||
history: createWebHistory(), | ||
linkActiveClass: 'active', | ||
routes: [ | ||
{ path: '/', component: () => import(/* webpackChunkName: "home" */ './pages/home.vue') }, | ||
{ path: '/install', component: () => import(/* webpackChunkName: "install" */ './pages/install.vue') }, | ||
{ path: '/selects', component: () => import(/* webpackChunkName: "selects" */ './pages/selects.vue') }, | ||
{ path: '/options', component: () => import(/* webpackChunkName: "options" */ './pages/options.vue') }, | ||
{ path: '/methods', component: () => import(/* webpackChunkName: "methods" */ './pages/methods.vue') } | ||
] | ||
{ | ||
path: '/', | ||
name: 'Home', | ||
component: () => import('./pages/home.vue'), | ||
}, | ||
{ | ||
path: '/install', | ||
name: 'Install', | ||
component: () => import('./pages/install.vue'), | ||
}, | ||
{ | ||
path: '/selects', | ||
name: 'Selects', | ||
component: () => import('./pages/selects.vue'), | ||
}, | ||
{ | ||
path: '/data', | ||
name: 'Data', | ||
component: () => import('./pages/data.vue'), | ||
}, | ||
{ | ||
path: '/settings', | ||
name: 'Settings', | ||
component: () => import('./pages/settings/index.vue'), | ||
}, | ||
{ | ||
path: '/events', | ||
name: 'Events', | ||
component: () => import('./pages/events/index.vue'), | ||
}, | ||
{ | ||
path: '/methods', | ||
name: 'Methods', | ||
component: () => import('./pages/methods/index.vue'), | ||
}, | ||
], | ||
}) | ||
export default router |
@@ -0,4 +1,18 @@ | ||
// Generate an 8 character random string | ||
export function generateID(): string { | ||
return Math.random().toString(36).substring(2, 10) | ||
} | ||
export function hasClassInTree(element: HTMLElement, className: string) { | ||
function hasClass(e: HTMLElement, c: string) { | ||
if (!(!c || !e || !e.classList || !e.classList.contains(c))) { return e } | ||
// If the element has the class return element | ||
if (c && e && e.classList && e.classList.contains(c)) { | ||
return e | ||
} | ||
// If the element has a dataset id of the class return element | ||
if (c && e && e.dataset && e.dataset.id && e.dataset.id === className) { | ||
return e | ||
} | ||
return null | ||
@@ -8,3 +22,3 @@ } | ||
function parentByClass(e: any, c: string): any { | ||
if (!e || e === document as any) { | ||
if (!e || e === (document as any)) { | ||
return null | ||
@@ -21,37 +35,11 @@ } else if (hasClass(e, c)) { | ||
export function ensureElementInView(container: HTMLElement, element: HTMLElement): void { | ||
// Determine container top and bottom | ||
const cTop = container.scrollTop + container.offsetTop // Make sure to have offsetTop | ||
const cBottom = cTop + container.clientHeight | ||
// Determine element top and bottom | ||
const eTop = element.offsetTop | ||
const eBottom = eTop + element.clientHeight | ||
// Check if out of view | ||
if (eTop < cTop) { | ||
container.scrollTop -= (cTop - eTop) | ||
} else if (eBottom > cBottom) { | ||
container.scrollTop += (eBottom - cBottom) | ||
} | ||
} | ||
export function putContent(el: HTMLElement, currentPosition: string, isOpen: boolean): string { | ||
const height = el.offsetHeight | ||
const rect = el.getBoundingClientRect() | ||
const elemTop = (isOpen ? rect.top : rect.top - height) | ||
const elemBottom = (isOpen ? rect.bottom : rect.bottom + height) | ||
if (elemTop <= 0) { return 'below' } | ||
if (elemBottom >= window.innerHeight) { return 'above' } | ||
return (isOpen ? currentPosition : 'below') | ||
} | ||
export function debounce(func: (...params: any[]) => void, wait = 100, immediate = false): () => void { | ||
export function debounce(func: (...params: any[]) => void, wait = 50, immediate = false): () => void { | ||
let timeout: any | ||
return function(this: any, ...args: any[]) { | ||
return function (this: any, ...args: any[]) { | ||
const context = self | ||
const later = () => { | ||
timeout = null | ||
if (!immediate) { func.apply(context, args) } | ||
if (!immediate) { | ||
func.apply(context, args) | ||
} | ||
} | ||
@@ -61,60 +49,11 @@ const callNow = immediate && !timeout | ||
timeout = setTimeout(later, wait) | ||
if (callNow) { func.apply(context, args) } | ||
} | ||
} | ||
export function isValueInArrayOfObjects(selected: any, key: string, value: string): boolean { | ||
if (!Array.isArray(selected)) { | ||
return selected[key] === value | ||
} | ||
for (const s of selected) { | ||
if (s && s[key] && s[key] === value) { | ||
return true | ||
if (callNow) { | ||
func.apply(context, args) | ||
} | ||
} | ||
return false | ||
} | ||
export function highlight(str: string, search: any, className: string) { | ||
// the completed string will be itself if already set, otherwise, the string that was passed in | ||
let completedString: any = str | ||
const regex = new RegExp('(' + search.trim() + ')(?![^<]*>[^<>]*</)', 'i') | ||
// If the regex doesn't match the string just exit | ||
if (!str.match(regex)) { return str } | ||
// Otherwise, get to highlighting | ||
const matchStartPosition = (str.match(regex) as any).index | ||
const matchEndPosition = matchStartPosition + (str.match(regex) as any)[0].toString().length | ||
const originalTextFoundByRegex = str.substring(matchStartPosition, matchEndPosition) | ||
completedString = completedString.replace(regex, `<mark class="${className}">${originalTextFoundByRegex}</mark>`) | ||
return completedString | ||
} | ||
export function kebabCase(str: string) { | ||
const result = str.replace( | ||
/[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g, | ||
(match) => '-' + match.toLowerCase() | ||
) | ||
return (str[0] === str[0].toUpperCase()) | ||
? result.substring(1) | ||
: result | ||
const result = str.replace(/[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g, (match) => '-' + match.toLowerCase()) | ||
return str[0] === str[0].toUpperCase() ? result.substring(1) : result | ||
} | ||
// Custom events | ||
(() => { | ||
const w = (window as any) | ||
if (typeof w.CustomEvent === 'function') { return } | ||
function CustomEvent(event: any, params: any) { | ||
params = params || { bubbles: false, cancelable: false, detail: undefined } | ||
const evt = document.createEvent('CustomEvent') | ||
evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail) | ||
return evt | ||
} | ||
CustomEvent.prototype = w.Event.prototype | ||
w.CustomEvent = CustomEvent | ||
})() |
@@ -1,37 +0,30 @@ | ||
import { Config } from './config' | ||
import { Select } from './select' | ||
import { Slim } from './slim' | ||
import { Data, dataArray, Option, validateData } from './data' | ||
import { hasClassInTree, putContent, debounce, ensureElementInView } from './helper' | ||
import { debounce, hasClassInTree } from './helper' | ||
import Render from './render' | ||
import Select from './select' | ||
import Settings, { SettingsPartial } from './settings' | ||
import Store, { DataArray, DataArrayPartial, Option, OptionOptional } from './store' | ||
interface Constructor { | ||
// Export everything except the "export default" | ||
export * from './helper' | ||
export * from './settings' | ||
export * from './select' | ||
export * from './store' | ||
export * from './render' | ||
// Export all "export defaults" | ||
export { Settings, Select, Store, Render } | ||
export interface Config { | ||
select: string | Element | ||
data?: dataArray | ||
showSearch?: boolean | ||
searchPlaceholder?: string | ||
searchText?: string | ||
searchingText?: string | ||
searchFocus?: boolean | ||
searchHighlight?: boolean | ||
searchFilter?: (opt: Option, search: string) => boolean | ||
closeOnSelect?: boolean | ||
showContent?: string | ||
placeholder?: string | ||
allowDeselect?: boolean | ||
allowDeselectOption?: boolean | ||
hideSelectedOption?: boolean | ||
deselectLabel?: string | ||
isEnabled?: boolean | ||
valuesUseText?: boolean // Use text value when showing selected value | ||
showOptionTooltips?: boolean | ||
selectByGroup?: boolean | ||
limit?: number | ||
timeoutDelay?: number | ||
addToBody?: boolean | ||
data?: DataArrayPartial | ||
settings?: SettingsPartial | ||
events?: Events | ||
} | ||
// Events | ||
ajax?: (value: string, func: (info: any) => void) => void | ||
addable?: (value: string) => Option | string | ||
beforeOnChange?: (info: Option | Option[]) => void | boolean | ||
onChange?: (info: Option | Option[]) => void | ||
export interface Events { | ||
search?: (searchValue: string, currentData: DataArray) => Promise<DataArrayPartial> | DataArrayPartial | ||
searchFilter?: (option: Option, search: string) => boolean | ||
addable?: (value: string) => OptionOptional | string | ||
beforeChange?: (newVal: Option[], oldVal: Option[]) => boolean | void | ||
afterChange?: (newVal: Option[]) => void | ||
beforeOpen?: () => void | ||
@@ -41,492 +34,396 @@ afterOpen?: () => void | ||
afterClose?: () => void | ||
error?: (err: Error) => void | ||
} | ||
export default class SlimSelect { | ||
public config: Config | ||
public select: Select | ||
public data: Data | ||
public slim: Slim | ||
public ajax: ((value: string, func: (info: any) => void) => void) | null = null | ||
public addable: ((value: string) => Option | string) | null = null | ||
public beforeOnChange: ((info: Option) => void | boolean) | null = null | ||
public onChange: ((info: Option) => void) | null = null | ||
public beforeOpen: (() => void) | null = null | ||
public afterOpen: (() => void) | null = null | ||
public beforeClose: (() => void) | null = null | ||
public afterClose: (() => void) | null = null | ||
public selectEl: HTMLSelectElement | ||
private windowScroll: (e: Event) => void = debounce((e: Event) => { | ||
if (this.data.contentOpen) { | ||
if (putContent(this.slim.content, this.data.contentPosition, this.data.contentOpen) === 'above') { | ||
this.moveContentAbove() | ||
} else { | ||
this.moveContentBelow() | ||
// Classes | ||
public settings!: Settings | ||
public select!: Select | ||
public store!: Store | ||
public render!: Render | ||
// Events | ||
public events = { | ||
search: undefined, | ||
searchFilter: (opt: Option, search: string) => { | ||
return opt.text.toLowerCase().indexOf(search.toLowerCase()) !== -1 | ||
}, | ||
addable: undefined, | ||
beforeChange: undefined, | ||
afterChange: undefined, | ||
beforeOpen: undefined, | ||
afterOpen: undefined, | ||
beforeClose: undefined, | ||
afterClose: undefined, | ||
} as Events | ||
constructor(config: Config) { | ||
// Make sure you get the right element | ||
this.selectEl = ( | ||
typeof config.select === 'string' ? document.querySelector(config.select) : config.select | ||
) as HTMLSelectElement | ||
if (!this.selectEl) { | ||
if (config.events && config.events.error) { | ||
config.events.error(new Error('Could not find select element')) | ||
} | ||
return | ||
} | ||
}) | ||
if (this.selectEl.tagName !== 'SELECT') { | ||
if (config.events && config.events.error) { | ||
config.events.error(new Error('Element isnt of type select')) | ||
} | ||
return | ||
} | ||
constructor(info: Constructor) { | ||
const selectElement = this.validate(info) | ||
// If select already has a slim select id on it lets destroy it first | ||
if (selectElement.dataset.ssid) { this.destroy(selectElement.dataset.ssid) } | ||
if (this.selectEl.dataset.ssid) { | ||
this.destroy() | ||
} | ||
// Set ajax function if passed in | ||
if (info.ajax) { this.ajax = info.ajax } | ||
// Set settings | ||
this.settings = new Settings(config.settings) | ||
// Add addable if option is passed in | ||
if (info.addable) { this.addable = info.addable } | ||
// Set events | ||
for (const key in config.events) { | ||
if (config.events.hasOwnProperty(key)) { | ||
;(this.events as { [key: string]: any })[key] = (config.events as { [key: string]: any })[key] | ||
} | ||
} | ||
this.config = new Config({ | ||
select: selectElement, | ||
isAjax: (info.ajax ? true : false), | ||
showSearch: info.showSearch, | ||
searchPlaceholder: info.searchPlaceholder, | ||
searchText: info.searchText, | ||
searchingText: info.searchingText, | ||
searchFocus: info.searchFocus, | ||
searchHighlight: info.searchHighlight, | ||
searchFilter: info.searchFilter, | ||
closeOnSelect: info.closeOnSelect, | ||
showContent: info.showContent, | ||
placeholderText: info.placeholder, | ||
allowDeselect: info.allowDeselect, | ||
allowDeselectOption: info.allowDeselectOption, | ||
hideSelectedOption: info.hideSelectedOption, | ||
deselectLabel: info.deselectLabel, | ||
isEnabled: info.isEnabled, | ||
valuesUseText: info.valuesUseText, | ||
showOptionTooltips: info.showOptionTooltips, | ||
selectByGroup: info.selectByGroup, | ||
limit: info.limit, | ||
timeoutDelay: info.timeoutDelay, | ||
addToBody: info.addToBody | ||
}) | ||
// Upate settings with type, style and classname | ||
this.settings.isMultiple = this.selectEl.multiple | ||
this.settings.style = this.selectEl.style.cssText | ||
this.settings.class = this.selectEl.className.split(' ') | ||
this.select = new Select({ | ||
select: selectElement, | ||
main: this | ||
// Set select class | ||
this.select = new Select(this.selectEl) | ||
this.select.updateSelect(this.settings.id, this.settings.style, this.settings.class) | ||
this.select.hideUI() // Hide the original select element | ||
// Add select listeners | ||
this.select.addSelectChangeListener((data: DataArray) => { | ||
// Run set data from the values given | ||
this.setData(data) | ||
}) | ||
this.select.addValueChangeListener((values: string[]) => { | ||
// Run set selected from the values given | ||
this.setSelected(values) | ||
}) | ||
this.data = new Data({ main: this }) | ||
this.slim = new Slim({ main: this }) | ||
// Set store class | ||
this.store = new Store( | ||
this.settings.isMultiple ? 'multiple' : 'single', | ||
config.data ? config.data : this.select.getData(), | ||
) | ||
// Add after original select element | ||
if (this.select.element.parentNode) { | ||
this.select.element.parentNode.insertBefore(this.slim.container, this.select.element.nextSibling) | ||
// If data is passed update the original select element | ||
if (config.data) { | ||
this.select.updateOptions(this.store.getData()) | ||
} | ||
// If data is passed in lets set it | ||
// and thus will start the render | ||
if (info.data) { | ||
this.setData(info.data) | ||
} else { | ||
// Do an initial render on startup | ||
this.render() | ||
// Set render callbacks | ||
const callbacks = { | ||
open: this.open.bind(this), | ||
close: this.close.bind(this), | ||
addable: this.events.addable ? this.events.addable : undefined, | ||
setSelected: this.setSelected.bind(this), | ||
addOption: this.addOption.bind(this), | ||
search: this.search.bind(this), | ||
beforeChange: this.events.beforeChange, | ||
afterChange: this.events.afterChange, | ||
} | ||
// Setup render class | ||
this.render = new Render(this.settings, this.store, callbacks) | ||
// Add render after original select element | ||
if (this.selectEl.parentNode) { | ||
this.selectEl.parentNode.insertBefore(this.render.main.main, this.selectEl.nextSibling) | ||
} | ||
// Add onclick listener to document to closeContent if clicked outside | ||
document.addEventListener('click', this.documentClick) | ||
// Add window resize listener to moveContent if window size changes | ||
window.addEventListener('resize', this.windowResize, false) | ||
// If the user wants to show the content forcibly on a specific side, | ||
// there is no need to listen for scroll events | ||
if (this.config.showContent === 'auto') { | ||
if (this.settings.openPosition === 'auto') { | ||
window.addEventListener('scroll', this.windowScroll, false) | ||
} | ||
// Add event callbacks after everthing has been created | ||
if (info.beforeOnChange) { this.beforeOnChange = info.beforeOnChange } | ||
if (info.onChange) { this.onChange = info.onChange } | ||
if (info.beforeOpen) { this.beforeOpen = info.beforeOpen } | ||
if (info.afterOpen) { this.afterOpen = info.afterOpen } | ||
if (info.beforeClose) { this.beforeClose = info.beforeClose } | ||
if (info.afterClose) { this.afterClose = info.afterClose } | ||
// If disabled lets call it | ||
if (!this.config.isEnabled) { this.disable() } | ||
} | ||
if (!this.settings.isEnabled) { | ||
this.disable() | ||
} | ||
public validate(info: Constructor) { | ||
const select = (typeof info.select === 'string' ? document.querySelector(info.select) : info.select) as HTMLSelectElement | ||
if (!select) { throw new Error('Could not find select element') } | ||
if (select.tagName !== 'SELECT') { throw new Error('Element isnt of type select') } | ||
return select | ||
// If alwaysOpnen then open it | ||
if (this.settings.alwaysOpen) { | ||
this.open() | ||
} | ||
// Add SlimSelect to select element | ||
;(this.selectEl as any).slim = this | ||
} | ||
public selected(): string | string[] { | ||
if (this.config.isMultiple) { | ||
const selected = this.data.getSelected() as Option[] | ||
const outputSelected: string[] = [] | ||
for (const s of selected) { | ||
outputSelected.push(s.value as string) | ||
} | ||
return outputSelected | ||
} else { | ||
const selected = this.data.getSelected() as Option | ||
return (selected ? selected.value as string : '') | ||
} | ||
// Set to enabled and remove disabled classes | ||
public enable(): void { | ||
this.settings.isEnabled = true | ||
this.select.enable() | ||
this.render.enable() | ||
} | ||
// Sets value of the select, adds it to data and original select | ||
public set(value: string | string[], type: string = 'value', close: boolean = true, render: boolean = true) { | ||
if (this.config.isMultiple && !Array.isArray(value)) { | ||
this.data.addToSelected(value, type) | ||
} else { | ||
this.data.setSelected(value, type) | ||
} | ||
this.select.setValue() | ||
this.data.onDataChange() // Trigger on change callback | ||
this.render() | ||
// Set to disabled and add disabled classes | ||
public disable(): void { | ||
this.settings.isEnabled = false | ||
// Close when all options are selected and hidden | ||
if (this.config.hideSelectedOption && this.config.isMultiple && (this.data.getSelected() as Option[]).length === this.data.data.length) { | ||
close = true | ||
} | ||
if (close) { this.close() } | ||
this.select.disable() | ||
this.render.disable() | ||
} | ||
// setSelected is just mapped to the set method | ||
public setSelected(value: string | string[], type: string = 'value', close: boolean = true, render: boolean = true) { | ||
this.set(value, type, close, render) | ||
public getData(): DataArray { | ||
return this.store.getData() | ||
} | ||
public setData(data: dataArray) { | ||
// Validate data if passed in | ||
const isValid = validateData(data) | ||
if (!isValid) { console.error('Validation problem on: #' + this.select.element.id); return } // If data passed in is not valid DO NOT parse, set and render | ||
const newData = JSON.parse(JSON.stringify(data)) | ||
const selected = this.data.getSelected() | ||
// Check newData to make sure value is set | ||
// If not set from text | ||
for (let i = 0; i < newData.length; i++) { | ||
if (!newData[i].value && !newData[i].placeholder) { | ||
newData[i].value = newData[i].text | ||
public setData(data: DataArrayPartial): void { | ||
// Validate data | ||
const err = this.store.validateDataArray(data) | ||
if (err) { | ||
if (this.events.error) { | ||
this.events.error(err) | ||
} | ||
return | ||
} | ||
// If its an ajax type keep selected values | ||
if (this.config.isAjax && selected) { | ||
if (this.config.isMultiple) { | ||
const reverseSelected = (selected as Option[]).reverse() | ||
for (const r of reverseSelected) { | ||
newData.unshift(r) | ||
} | ||
} else { | ||
newData.unshift(selected) | ||
// Update the store | ||
this.store.setData(data) | ||
const dataClean = this.store.getData() | ||
// Look for duplicate selected if so remove it | ||
for (let i = 0; i < newData.length; i++) { | ||
if (!newData[i].placeholder && newData[i].value === (selected as Option).value && newData[i].text === (selected as Option).text) { | ||
newData.splice(i, 1) | ||
} | ||
} | ||
// Update original select element | ||
this.select.updateOptions(dataClean) | ||
// Add placeholder if it doesnt already have one | ||
let hasPlaceholder = false | ||
for (let i = 0; i < newData.length; i++) { | ||
if (newData[i].placeholder) { | ||
hasPlaceholder = true | ||
} | ||
} | ||
if (!hasPlaceholder) { | ||
newData.unshift({ text: '', placeholder: true }) | ||
} | ||
} | ||
} | ||
// Update the render | ||
this.render.renderValues() | ||
this.render.renderOptions(dataClean) | ||
} | ||
this.select.create(newData) | ||
this.data.parseSelectData() | ||
this.data.setSelectedFromSelect() | ||
public getSelected(): string[] { | ||
return this.store.getSelected() | ||
} | ||
// addData will append to the current data set | ||
public addData(data: Option) { | ||
// Validate data if passed in | ||
const isValid = validateData([data]) | ||
if (!isValid) { console.error('Validation problem on: #' + this.select.element.id); return } // If data passed in is not valid DO NOT parse, set and render | ||
public setSelected(value: string | string[]): void { | ||
// Update the store | ||
this.store.setSelectedBy('value', Array.isArray(value) ? value : [value]) | ||
const data = this.store.getData() | ||
this.data.add(this.data.newOption(data)) | ||
this.select.create(this.data.data) | ||
this.data.parseSelectData() | ||
this.data.setSelectedFromSelect() | ||
this.render() | ||
// Update the select element | ||
this.select.updateOptions(data) | ||
// Update the render | ||
this.render.renderValues() | ||
this.render.renderOptions(data) | ||
} | ||
// Open content section | ||
public addOption(option: OptionOptional): void { | ||
// Add option to store | ||
this.store.addOption(option) | ||
const data = this.store.getData() | ||
// Update the select element | ||
this.select.updateOptions(data) | ||
// Update the render | ||
this.render.renderValues() | ||
this.render.renderOptions(data) | ||
} | ||
public open(): void { | ||
// Dont open if disabled | ||
if (!this.config.isEnabled) { return } | ||
// Dont do anything if the content is already open | ||
if (this.data.contentOpen) { return } | ||
if (!this.settings.isEnabled || this.settings.isOpen) { | ||
return | ||
} | ||
// Dont open when all options are selected and hidden | ||
if (this.config.hideSelectedOption && this.config.isMultiple && (this.data.getSelected() as Option[]).length === this.data.data.length) { return } | ||
// Run beforeOpen callback | ||
if (this.beforeOpen) { this.beforeOpen() } | ||
if (this.config.isMultiple && this.slim.multiSelected) { | ||
this.slim.multiSelected.plus.classList.add('ss-cross') | ||
} else if (this.slim.singleSelected) { | ||
this.slim.singleSelected.arrowIcon.arrow.classList.remove('arrow-down') | ||
this.slim.singleSelected.arrowIcon.arrow.classList.add('arrow-up') | ||
if (this.events.beforeOpen) { | ||
this.events.beforeOpen() | ||
} | ||
(this.slim as any)[(this.config.isMultiple ? 'multiSelected' : 'singleSelected')].container.classList.add((this.data.contentPosition === 'above' ? this.config.openAbove : this.config.openBelow)) | ||
if (this.config.addToBody) { | ||
// move the content in to the right location | ||
const containerRect = this.slim.container.getBoundingClientRect() | ||
this.slim.content.style.top = (containerRect.top + containerRect.height + window.scrollY) + 'px' | ||
this.slim.content.style.left = (containerRect.left + window.scrollX) + 'px' | ||
this.slim.content.style.width = containerRect.width + 'px' | ||
} | ||
this.slim.content.classList.add(this.config.open) | ||
// Tell render to open | ||
this.render.open() | ||
// Check showContent to see if they want to specifically show in a certain direction | ||
if (this.config.showContent.toLowerCase() === 'up') { | ||
this.moveContentAbove() | ||
} else if (this.config.showContent.toLowerCase() === 'down') { | ||
this.moveContentBelow() | ||
} else { | ||
// Auto identify where to put it | ||
if (putContent(this.slim.content, this.data.contentPosition, this.data.contentOpen) === 'above') { | ||
this.moveContentAbove() | ||
} else { | ||
this.moveContentBelow() | ||
} | ||
// Focus on input field only if search is enabled | ||
if (this.settings.showSearch) { | ||
this.render.searchFocus(false) | ||
} | ||
// Move to selected option for single option | ||
if (!this.config.isMultiple) { | ||
const selected = this.data.getSelected() as Option | ||
if (selected) { | ||
const selectedId = selected.id | ||
const selectedOption = this.slim.list.querySelector('[data-id="' + selectedId + '"]') as HTMLElement | ||
if (selectedOption) { | ||
ensureElementInView(this.slim.list, selectedOption) | ||
} | ||
} | ||
} | ||
// setTimeout is for animation completion | ||
setTimeout(() => { | ||
this.data.contentOpen = true | ||
// Focus on input field | ||
if (this.config.searchFocus) { | ||
this.slim.search.input.focus() | ||
// Run afterOpen callback | ||
if (this.events.afterOpen) { | ||
this.events.afterOpen() | ||
} | ||
// Run afterOpen callback | ||
if (this.afterOpen) { | ||
this.afterOpen() | ||
} | ||
}, this.config.timeoutDelay) | ||
// Update settings | ||
this.settings.isOpen = true | ||
}, this.settings.timeoutDelay) | ||
// Start an interval to check if main has moved | ||
// in order to keep content close to main | ||
if (this.settings.intervalMove) { | ||
clearInterval(this.settings.intervalMove) | ||
} | ||
this.settings.intervalMove = setInterval(this.render.moveContent.bind(this.render), 500) | ||
} | ||
// Close content section | ||
public close(): void { | ||
// Dont do anything if the content is already closed | ||
if (!this.data.contentOpen) { return } | ||
// Dont do anything if alwaysOpen is true | ||
if (!this.settings.isOpen || this.settings.alwaysOpen) { | ||
return | ||
} | ||
// Run beforeClose calback | ||
if (this.beforeClose) { this.beforeClose() } | ||
// this.slim.search.input.blur() // Removed due to safari quirk | ||
if (this.config.isMultiple && this.slim.multiSelected) { | ||
this.slim.multiSelected.container.classList.remove(this.config.openAbove) | ||
this.slim.multiSelected.container.classList.remove(this.config.openBelow) | ||
this.slim.multiSelected.plus.classList.remove('ss-cross') | ||
} else if (this.slim.singleSelected) { | ||
this.slim.singleSelected.container.classList.remove(this.config.openAbove) | ||
this.slim.singleSelected.container.classList.remove(this.config.openBelow) | ||
this.slim.singleSelected.arrowIcon.arrow.classList.add('arrow-down') | ||
this.slim.singleSelected.arrowIcon.arrow.classList.remove('arrow-up') | ||
if (this.events.beforeClose) { | ||
this.events.beforeClose() | ||
} | ||
this.slim.content.classList.remove(this.config.open) | ||
this.data.contentOpen = false | ||
// Tell render to close | ||
this.render.close() | ||
// Clear search | ||
this.search('') // Clear search | ||
// If we arent tabbing focus back on the main element | ||
this.render.mainFocus(false) | ||
// Reset the content below | ||
setTimeout(() => { | ||
this.slim.content.removeAttribute('style') | ||
this.data.contentPosition = 'below' | ||
if (this.config.isMultiple && this.slim.multiSelected) { | ||
this.slim.multiSelected.container.classList.remove(this.config.openAbove) | ||
this.slim.multiSelected.container.classList.remove(this.config.openBelow) | ||
} else if (this.slim.singleSelected) { | ||
this.slim.singleSelected.container.classList.remove(this.config.openAbove) | ||
this.slim.singleSelected.container.classList.remove(this.config.openBelow) | ||
// Run afterClose callback | ||
if (this.events.afterClose) { | ||
this.events.afterClose() | ||
} | ||
// After content is closed lets blur on the input field | ||
this.slim.search.input.blur() | ||
// Update settings | ||
this.settings.isOpen = false | ||
}, this.settings.timeoutDelay) | ||
// Run afterClose callback | ||
if (this.afterClose) { this.afterClose() } | ||
}, this.config.timeoutDelay) | ||
} | ||
public moveContentAbove(): void { | ||
let selectHeight: number = 0 | ||
if (this.config.isMultiple && this.slim.multiSelected) { | ||
selectHeight = this.slim.multiSelected.container.offsetHeight | ||
} else if (this.slim.singleSelected) { | ||
selectHeight = this.slim.singleSelected.container.offsetHeight | ||
if (this.settings.intervalMove) { | ||
clearInterval(this.settings.intervalMove) | ||
} | ||
const contentHeight = this.slim.content.offsetHeight | ||
const height = selectHeight + contentHeight - 1 | ||
this.slim.content.style.margin = '-' + height + 'px 0 0 0' | ||
this.slim.content.style.height = (height - selectHeight + 1) + 'px' | ||
this.slim.content.style.transformOrigin = 'center bottom' | ||
this.data.contentPosition = 'above' | ||
if (this.config.isMultiple && this.slim.multiSelected) { | ||
this.slim.multiSelected.container.classList.remove(this.config.openBelow) | ||
this.slim.multiSelected.container.classList.add(this.config.openAbove) | ||
} else if (this.slim.singleSelected) { | ||
this.slim.singleSelected.container.classList.remove(this.config.openBelow) | ||
this.slim.singleSelected.container.classList.add(this.config.openAbove) | ||
} | ||
} | ||
public moveContentBelow(): void { | ||
this.data.contentPosition = 'below' | ||
if (this.config.isMultiple && this.slim.multiSelected) { | ||
this.slim.multiSelected.container.classList.remove(this.config.openAbove) | ||
this.slim.multiSelected.container.classList.add(this.config.openBelow) | ||
} else if (this.slim.singleSelected) { | ||
this.slim.singleSelected.container.classList.remove(this.config.openAbove) | ||
this.slim.singleSelected.container.classList.add(this.config.openBelow) | ||
// Take in string value and search current options | ||
public search(value: string): void { | ||
// If the passed in value is not the same as the search input value | ||
// then lets update the search input value | ||
if (this.render.content.search.input.value !== value) { | ||
this.render.content.search.input.value = value | ||
} | ||
} | ||
// Set to enabled, remove disabled classes and removed disabled from original select | ||
public enable(): void { | ||
this.config.isEnabled = true | ||
if (this.config.isMultiple && this.slim.multiSelected) { | ||
this.slim.multiSelected.container.classList.remove(this.config.disabled) | ||
} else if (this.slim.singleSelected) { | ||
this.slim.singleSelected.container.classList.remove(this.config.disabled) | ||
// If no search event run regular search | ||
if (!this.events.search) { | ||
// If value is empty then render all options | ||
this.render.renderOptions( | ||
value === '' ? this.store.getData() : this.store.search(value, this.events.searchFilter!), | ||
) | ||
return | ||
} | ||
// Disable original select but dont trigger observer | ||
this.select.triggerMutationObserver = false | ||
this.select.element.disabled = false | ||
this.slim.search.input.disabled = false | ||
this.select.triggerMutationObserver = true | ||
} | ||
// Search event exists so lets render the searching text | ||
this.render.renderSearching() | ||
// Set to disabled, add disabled classes and add disabled to original select | ||
public disable(): void { | ||
this.config.isEnabled = false | ||
if (this.config.isMultiple && this.slim.multiSelected) { | ||
this.slim.multiSelected.container.classList.add(this.config.disabled) | ||
} else if (this.slim.singleSelected) { | ||
this.slim.singleSelected.container.classList.add(this.config.disabled) | ||
} | ||
// Based upon the search event deal with the response | ||
const searchResp = this.events.search(value, this.store.getSelectedOptions()) | ||
// Enable original select but dont trigger observer | ||
this.select.triggerMutationObserver = false | ||
this.select.element.disabled = true | ||
this.slim.search.input.disabled = true | ||
this.select.triggerMutationObserver = true | ||
} | ||
// If the search event returns a promise | ||
if (searchResp instanceof Promise) { | ||
searchResp | ||
.then((data: DataArrayPartial) => { | ||
// Update the render with the new data | ||
this.render.renderOptions(this.store.partialToFullData(data)) | ||
}) | ||
.catch((err: Error | string) => { | ||
// Update the render with error | ||
this.render.renderError(typeof err === 'string' ? err : err.message) | ||
}) | ||
// Take in string value and search current options | ||
public search(value: string): void { | ||
// Only filter data and rerender if value has changed | ||
if (this.data.searchValue === value) { return } | ||
this.slim.search.input.value = value | ||
if (this.config.isAjax) { | ||
const master = this | ||
this.config.isSearching = true | ||
this.render() | ||
// If ajax call it | ||
if (this.ajax) { | ||
this.ajax(value, (info: any) => { | ||
// Only process if return callback is not false | ||
master.config.isSearching = false | ||
if (Array.isArray(info)) { | ||
info.unshift({ text: '', placeholder: true }) | ||
master.setData(info) | ||
master.data.search(value) | ||
master.render() | ||
} else if (typeof info === 'string') { | ||
master.slim.options(info) | ||
} else { | ||
master.render() | ||
} | ||
}) | ||
} | ||
return | ||
} else if (Array.isArray(searchResp)) { | ||
// Update the render options | ||
this.render.renderOptions(this.store.partialToFullData(searchResp)) | ||
} else { | ||
this.data.search(value) | ||
this.render() | ||
// Update the render with error | ||
this.render.renderError('Search event must return a promise or an array of data') | ||
} | ||
} | ||
public setSearchText(text: string): void { | ||
this.config.searchText = text | ||
} | ||
public render(): void { | ||
if (this.config.isMultiple) { | ||
this.slim.values() | ||
} else { | ||
this.slim.placeholder() | ||
this.slim.deselect() | ||
public destroy(): void { | ||
// Remove all event listeners | ||
document.removeEventListener('click', this.documentClick) | ||
window.removeEventListener('resize', this.windowResize, false) | ||
if (this.settings.openPosition === 'auto') { | ||
window.removeEventListener('scroll', this.windowScroll, false) | ||
} | ||
this.slim.options() | ||
} | ||
// Display original select again and remove slim | ||
public destroy(id: string | null = null): void { | ||
const slim = (id ? document.querySelector('.' + id + '.ss-main') : this.slim.container) | ||
const select = (id ? document.querySelector(`[data-ssid=${id}]`) as HTMLSelectElement : this.select.element) | ||
// If there is no slim dont do anything | ||
if (!slim || !select) { return } | ||
// Delete the store data | ||
this.store.setData([]) | ||
// Remove the render | ||
this.render.destroy() | ||
document.removeEventListener('click', this.documentClick) | ||
// Show the original select element | ||
this.select.destroy() | ||
} | ||
if (this.config.showContent === 'auto') { | ||
window.removeEventListener('scroll', this.windowScroll, false) | ||
private windowResize: (e: Event) => void = debounce(() => { | ||
if (!this.settings.isOpen) { | ||
return | ||
} | ||
// Show original select | ||
select.style.display = '' | ||
delete select.dataset.ssid | ||
this.render.moveContent() | ||
}) | ||
// Remove slim from original select dropdown | ||
const el = select as any | ||
el.slim = null | ||
// Event listener for window scrolling | ||
private windowScroll: (e: Event) => void = debounce(() => { | ||
// If the content is not open, there is no need to move it | ||
if (!this.settings.isOpen) { | ||
return | ||
} | ||
// Remove slim select | ||
if (slim.parentElement) { | ||
slim.parentElement.removeChild(slim) | ||
// If openContent is not auto set content | ||
if (this.settings.openPosition === 'down') { | ||
this.render.moveContentBelow() | ||
return | ||
} else if (this.settings.openPosition === 'up') { | ||
this.render.moveContentAbove() | ||
return | ||
} | ||
// remove the content if it was added to the document body | ||
if (this.config.addToBody) { | ||
const slimContent = (id ? document.querySelector('.' + id + '.ss-content') : this.slim.content) | ||
if (!slimContent) { return } | ||
document.body.removeChild(slimContent) | ||
// Determine where to put the content | ||
if (this.settings.contentPosition === 'relative') { | ||
this.render.moveContentBelow() | ||
} else if (this.render.putContent(this.render.content.main, this.settings.isOpen) === 'up') { | ||
this.render.moveContentAbove() | ||
} else { | ||
this.render.moveContentBelow() | ||
} | ||
} | ||
}) | ||
// Event listener for document click | ||
private documentClick: (e: Event) => void = (e: Event) => { | ||
if (e.target && !hasClassInTree(e.target as HTMLElement, this.config.id)) { | ||
// If the content is not open, there is no need to close it | ||
if (!this.settings.isOpen) { | ||
return | ||
} | ||
// Check if the click was on the content by looking at the parents | ||
if (e.target && !hasClassInTree(e.target as HTMLElement, this.settings.id)) { | ||
this.close() | ||
} | ||
} | ||
} |
@@ -1,147 +0,309 @@ | ||
import SlimSelect from './index' | ||
import { Option, Optgroup, dataArray } from './data' | ||
import { kebabCase } from './helper' | ||
import { generateID, kebabCase } from './helper' | ||
import { DataArray, DataObject, Optgroup, Option } from './store' | ||
interface Constructor { | ||
select: HTMLSelectElement | ||
main: SlimSelect | ||
} | ||
export default class Select { | ||
public select: HTMLSelectElement | ||
public listen: boolean = false | ||
export class Select { | ||
public element: HTMLSelectElement | ||
public main: SlimSelect | ||
public mutationObserver: MutationObserver | null | ||
public triggerMutationObserver: boolean = true | ||
constructor(info: Constructor) { | ||
this.element = info.select | ||
this.main = info.main | ||
// Mutation observer fields | ||
public onSelectChange?: (data: DataArray) => void | ||
public onValueChange?: (value: string[]) => void | ||
private observer: MutationObserver | null = null | ||
// If original select is set to disabled lets make sure slim is too | ||
if (this.element.disabled) { this.main.config.isEnabled = false } | ||
constructor(select: HTMLSelectElement) { | ||
this.select = select | ||
} | ||
this.addAttributes() | ||
this.addEventListeners() | ||
this.mutationObserver = null | ||
this.addMutationObserver() | ||
// Set to enabled | ||
public enable(): void { | ||
// Disable original select but dont trigger observer | ||
this.disconnectObserver() | ||
this.select.disabled = false | ||
this.connectObserver() | ||
} | ||
// Add slim to original select dropdown | ||
const el = this.element as any | ||
el.slim = info.main | ||
// Set to disabled | ||
public disable(): void { | ||
// Enable original select but dont trigger observer | ||
this.disconnectObserver() | ||
this.select.disabled = true | ||
this.connectObserver() | ||
} | ||
public setValue(): void { | ||
if (!this.main.data.getSelected()) { return } | ||
// Set misc attributes on the main select element | ||
public hideUI(): void { | ||
this.select.tabIndex = -1 | ||
this.select.style.display = 'none' | ||
this.select.setAttribute('aria-hidden', 'true') | ||
} | ||
if (this.main.config.isMultiple) { | ||
// If multiple loop through options and set selected | ||
const selected = this.main.data.getSelected() as Option[] | ||
const options = this.element.options as any as HTMLOptionElement[] | ||
for (const o of options) { | ||
o.selected = false | ||
for (const s of selected) { | ||
if (s.value === o.value) { | ||
o.selected = true | ||
} | ||
} | ||
} | ||
public showUI(): void { | ||
this.select.removeAttribute('tabindex') | ||
this.select.style.display = '' | ||
this.select.removeAttribute('aria-hidden') | ||
} | ||
public changeListen(on: boolean) { | ||
this.listen = on | ||
// Deal with some observer situations | ||
if (this.listen) { | ||
this.connectObserver() | ||
} else { | ||
// If single select simply set value | ||
const selected = this.main.data.getSelected() as any | ||
this.element.value = (selected ? selected.value : '') | ||
this.disconnectObserver() | ||
} | ||
} | ||
// Do not trigger onChange callbacks for this event listener | ||
this.main.data.isOnChangeEnabled = false | ||
this.element.dispatchEvent(new CustomEvent('change', { bubbles: true })) | ||
this.main.data.isOnChangeEnabled = true | ||
// Add change listener to original select | ||
public addSelectChangeListener(func: (data: DataArray) => void): void { | ||
this.onSelectChange = func | ||
this.addObserver() | ||
this.connectObserver() | ||
this.changeListen(true) // Last start listening | ||
} | ||
public addAttributes() { | ||
this.element.tabIndex = -1 | ||
this.element.style.display = 'none' | ||
// remove change listener from original select | ||
public removeSelectChangeListener(): void { | ||
this.changeListen(false) // First stop listening | ||
this.onSelectChange = undefined | ||
} | ||
// Add slim select id | ||
this.element.dataset.ssid = this.main.config.id | ||
this.element.setAttribute('aria-hidden', 'true') | ||
public addValueChangeListener(func: (value: string[]) => void): void { | ||
this.onValueChange = func | ||
this.select.addEventListener('change', this.valueChange.bind(this)) | ||
} | ||
// Add onChange listener to original select | ||
public addEventListeners() { | ||
this.element.addEventListener('change', (e: Event) => { | ||
this.main.data.setSelectedFromSelect() | ||
this.main.render() | ||
}) | ||
public removeValueChangeListener(): void { | ||
this.onValueChange = undefined | ||
this.select.removeEventListener('change', this.valueChange.bind(this)) | ||
} | ||
public valueChange(ev: Event): any { | ||
if (this.onValueChange) { | ||
this.onValueChange(this.getSelectedValues()) | ||
} | ||
} | ||
private observeWrapper(mutations: MutationRecord[]): void { | ||
if (this.onSelectChange) { | ||
this.onSelectChange(this.getData()) | ||
} | ||
} | ||
// Add MutationObserver to select | ||
public addMutationObserver(): void { | ||
// Only add if not in ajax mode | ||
if (this.main.config.isAjax) { return } | ||
private addObserver(): void { | ||
// If mutation observer already exists then disconnect and | ||
if (this.observer) { | ||
this.disconnectObserver() | ||
this.observer = null | ||
} | ||
this.mutationObserver = new MutationObserver((mutations) => { | ||
if (!this.triggerMutationObserver) {return} | ||
// If anything changes in the select then update the data | ||
this.observer = new MutationObserver(this.observeWrapper) | ||
} | ||
this.main.data.parseSelectData() | ||
this.main.data.setSelectedFromSelect() | ||
this.main.render() | ||
private connectObserver(): void { | ||
if (this.observer) { | ||
this.observer.observe(this.select, { | ||
attributes: true, | ||
childList: true, | ||
characterData: true, | ||
subtree: true, | ||
}) | ||
} | ||
} | ||
mutations.forEach((mutation) => { | ||
if (mutation.attributeName === 'class') { | ||
this.main.slim.updateContainerDivClass(this.main.slim.container) | ||
private disconnectObserver(): void { | ||
if (this.observer) { | ||
this.observer.disconnect() | ||
} | ||
} | ||
// From the select element pull optgroup and options into data | ||
public getData(): DataArray { | ||
let data = [] | ||
// Loop through nodes and get data | ||
const nodes = this.select.childNodes as any as HTMLOptGroupElement[] | HTMLOptionElement[] | ||
for (const n of nodes) { | ||
// Optgroup | ||
if (n.nodeName === 'OPTGROUP') { | ||
data.push(this.getDataFromOptgroup(n as HTMLOptGroupElement)) | ||
} | ||
// Option | ||
if (n.nodeName === 'OPTION') { | ||
data.push(this.getDataFromOption(n as HTMLOptionElement)) | ||
} | ||
} | ||
return data | ||
} | ||
public getDataFromOptgroup(optgroup: HTMLOptGroupElement): Optgroup { | ||
let data = { | ||
id: '', | ||
label: optgroup.label, | ||
options: [], | ||
} as Optgroup | ||
const options = optgroup.childNodes as any as HTMLOptionElement[] | ||
for (const o of options) { | ||
if (o.nodeName === 'OPTION') { | ||
data.options.push(this.getDataFromOption(o as HTMLOptionElement)) | ||
} | ||
} | ||
return data | ||
} | ||
public getSelectedValues(): string[] { | ||
let values = [] | ||
// Loop through options and set selected | ||
const options = this.select.childNodes as any as (HTMLOptGroupElement | HTMLOptionElement)[] | ||
for (const o of options) { | ||
if (o.nodeName === 'OPTGROUP') { | ||
const optgroupOptions = o.childNodes as any as HTMLOptionElement[] | ||
for (const oo of optgroupOptions) { | ||
if (oo.nodeName === 'OPTION') { | ||
const option = oo as HTMLOptionElement | ||
if (option.selected) { | ||
values.push(option.value) | ||
} | ||
} | ||
} | ||
}) | ||
}) | ||
} | ||
this.observeMutationObserver() | ||
if (o.nodeName === 'OPTION') { | ||
const option = o as HTMLOptionElement | ||
if (option.selected) { | ||
values.push(option.value) | ||
} | ||
} | ||
} | ||
return values | ||
} | ||
public observeMutationObserver(): void { | ||
if (!this.mutationObserver) { return } | ||
// From passed in option pull pieces of usable information | ||
public getDataFromOption(option: HTMLOptionElement): Option { | ||
return { | ||
id: (option.dataset ? option.dataset.id : false) || generateID(), | ||
value: option.value, | ||
text: option.text, | ||
html: option.innerHTML, | ||
selected: option.selected, | ||
display: option.style.display === 'none' ? false : true, | ||
disabled: option.disabled, | ||
mandatory: option.dataset ? option.dataset.mandatory === 'true' : false, | ||
placeholder: option.dataset.placeholder === 'true', | ||
class: option.className, | ||
style: option.style.cssText, | ||
data: option.dataset, | ||
} as Option | ||
} | ||
this.mutationObserver.observe(this.element, { | ||
attributes: true, | ||
childList: true, | ||
characterData: true | ||
}) | ||
public setSelected(value: string[]): void { | ||
// Loop through options and set selected | ||
const options = this.select.childNodes as any as (HTMLOptGroupElement | HTMLOptionElement)[] | ||
for (const o of options) { | ||
if (o.nodeName === 'OPTGROUP') { | ||
const optgroup = o as HTMLOptGroupElement | ||
const optgroupOptions = optgroup.childNodes as any as HTMLOptionElement[] | ||
for (const oo of optgroupOptions) { | ||
if (oo.nodeName === 'OPTION') { | ||
const option = oo as HTMLOptionElement | ||
option.selected = value.includes(option.value) | ||
} | ||
} | ||
} | ||
if (o.nodeName === 'OPTION') { | ||
const option = o as HTMLOptionElement | ||
option.selected = value.includes(option.value) | ||
} | ||
} | ||
} | ||
public disconnectMutationObserver(): void { | ||
if (this.mutationObserver) { | ||
this.mutationObserver.disconnect() | ||
public updateSelect(id?: string, style?: string, classes?: string[]): void { | ||
// Stop listening to changes | ||
this.changeListen(false) | ||
// Update id | ||
if (id) { | ||
this.select.id = id | ||
} | ||
// Update style | ||
if (style) { | ||
this.select.style.cssText = style | ||
} | ||
// Update classes | ||
if (classes) { | ||
this.select.className = '' | ||
classes.forEach((c) => { | ||
if (c.trim() !== '') { | ||
this.select.classList.add(c.trim()) | ||
} | ||
}) | ||
} | ||
// Start listening to changes | ||
this.changeListen(true) | ||
} | ||
// Create select element and optgroup/options | ||
public create(data: dataArray): void { | ||
public updateOptions(data: DataArray): void { | ||
// Stop listening to changes | ||
this.changeListen(false) | ||
// Clear out select | ||
this.element.innerHTML = '' | ||
this.select.innerHTML = '' | ||
for (const d of data) { | ||
if (d.hasOwnProperty('options')) { | ||
const optgroupObject = d as Optgroup | ||
const optgroupEl = document.createElement('optgroup') as HTMLOptGroupElement | ||
optgroupEl.label = optgroupObject.label | ||
if (optgroupObject.options) { | ||
for (const oo of optgroupObject.options) { | ||
optgroupEl.appendChild(this.createOption(oo)) | ||
} | ||
} | ||
this.element.appendChild(optgroupEl) | ||
} else { | ||
this.element.appendChild(this.createOption(d)) | ||
if (d instanceof Optgroup) { | ||
this.select.appendChild(this.createOptgroup(d)) | ||
} | ||
if (d instanceof Option) { | ||
this.select.appendChild(this.createOption(d)) | ||
} | ||
} | ||
// Start listening to changes | ||
this.changeListen(true) | ||
} | ||
public createOption(info: any): HTMLOptionElement { | ||
public createOptgroup(optgroup: Optgroup): HTMLOptGroupElement { | ||
const optgroupEl = document.createElement('optgroup') | ||
optgroupEl.id = optgroup.id | ||
optgroupEl.label = optgroup.label | ||
if (optgroup.options) { | ||
for (const o of optgroup.options) { | ||
optgroupEl.appendChild(this.createOption(o)) | ||
} | ||
} | ||
return optgroupEl | ||
} | ||
public createOption(info: Option): HTMLOptionElement { | ||
const optionEl = document.createElement('option') | ||
optionEl.value = info.value !== '' ? info.value : info.text | ||
optionEl.innerHTML = info.innerHTML || info.text | ||
if (info.selected) { optionEl.selected = info.selected } | ||
optionEl.innerHTML = info.html || info.text | ||
if (info.selected) { | ||
optionEl.selected = info.selected | ||
} | ||
if (info.disabled) { | ||
optionEl.disabled = true | ||
} | ||
if (info.display === false) { | ||
optionEl.style.display = 'none' | ||
} | ||
if (info.disabled) { optionEl.disabled = true } | ||
if (info.placeholder) { optionEl.setAttribute('data-placeholder', 'true') } | ||
if (info.mandatory) { optionEl.setAttribute('data-mandatory', 'true') } | ||
if (info.placeholder) { | ||
optionEl.setAttribute('data-placeholder', 'true') | ||
} | ||
if (info.mandatory) { | ||
optionEl.setAttribute('data-mandatory', 'true') | ||
} | ||
if (info.class) { | ||
@@ -160,2 +322,12 @@ info.class.split(' ').forEach((optionClass: string) => { | ||
} | ||
public destroy() { | ||
this.changeListen(false) | ||
this.disconnectObserver() | ||
this.removeSelectChangeListener() | ||
this.removeValueChangeListener() | ||
// show the original select | ||
this.showUI() | ||
} | ||
} |
{ | ||
"compilerOptions": { | ||
"strict": true, /* Enable all strict type-checking options. */ | ||
"declaration": true, /* Generates corresponding '.d.ts' file. */ | ||
"target": "es2016", | ||
"module": "esnext", | ||
"moduleResolution": "node", | ||
"strict": true /* Enable all strict type-checking options. */, | ||
"declaration": true /* Generates corresponding '.d.ts' file. */, | ||
"outDir": "../../dist", // Output to dist folder | ||
"removeComments": true /* Do not emit comments to output. */ | ||
} | ||
} | ||
"removeComments": true /* Do not emit comments to output. */, | ||
"importHelpers": true /* Import emit helpers from 'tslib'. */ | ||
}, | ||
"include": ["**/*.ts"], | ||
"exclude": ["**/*.test.ts"] | ||
} |
{ | ||
"compilerOptions": { | ||
"declaration": true, | ||
"target": "esnext", | ||
"useDefineForClassFields": true, | ||
"module": "esnext", | ||
"moduleResolution": "node", | ||
"strict": true, | ||
"jsx": "preserve", | ||
"allowJs": false, | ||
"sourceMap": true, | ||
"resolveJsonModule": true, | ||
"esModuleInterop": true, | ||
"importHelpers": true, | ||
"moduleResolution": "node", | ||
"esModuleInterop": true, | ||
"allowSyntheticDefaultImports": true, | ||
"sourceMap": true, | ||
"baseUrl": ".", | ||
"types": [ | ||
"webpack-env" | ||
], | ||
"paths": { | ||
"@/*": [ | ||
"src/*" | ||
] | ||
}, | ||
"lib": [ | ||
"esnext", | ||
"dom", | ||
"dom.iterable", | ||
"scripthost" | ||
] | ||
"lib": ["esnext", "dom"] | ||
}, | ||
"include": [ | ||
"src/**/*.ts", | ||
"src/**/*.tsx", | ||
"src/**/*.vue", | ||
"tests/**/*.ts", | ||
"tests/**/*.tsx" | ||
], | ||
"exclude": [ | ||
"node_modules" | ||
] | ||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
146
66
4106588
22
4890
4