diff --git a/.gitignore b/.gitignore index e2979fa..21d44dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .DS_Store npm-debug.log package-lock.json +lib \ No newline at end of file diff --git a/package.json b/package.json index d809b22..92fdc9b 100644 --- a/package.json +++ b/package.json @@ -2,19 +2,29 @@ "name": "atom-select-list", "version": "0.7.2", "description": "A general-purpose select list for use in Atom packages", - "main": "./src/select-list-view.js", + "main": "lib/select-list-view.js", + "files": [ + "lib/**/*" + ], "scripts": { - "test": "atom --test test" + "dev": "npm run typescript -- --watch", + "typescript": "tsc -p ./tsconfig.json", + "build": "npm run typescript", + "test": "atom --test test", + "prepare": "npm run build" }, "author": "", "license": "MIT", "atomTestRunner": "atom-mocha-test-runner", "devDependencies": { - "atom-mocha-test-runner": "^0.3.0", - "sinon": "^2.1.0" + "atom-mocha-test-runner": "^1.2.0", + "sinon": "^2", + "typescript": "^4.1.3", + "@types/atom": "^1.40.5", + "@types/fuzzaldrin": "^2.1.3" }, "dependencies": { - "etch": "^0.12.6", + "etch": "^0.14.0", "fuzzaldrin": "^2.1.0" }, "repository": { diff --git a/src/select-list-properties.ts b/src/select-list-properties.ts new file mode 100644 index 0000000..db32b39 --- /dev/null +++ b/src/select-list-properties.ts @@ -0,0 +1,88 @@ +import { EtchElement } from './select-list-view' // TODO: etch types + +export interface SelectListProperties { + /** an array containing the objects you want to show in the select list. */ + items: Array + + /** + * a function that is called whenever an item needs to be displayed. + * + * `options: { selected: boolean, index: number, visible: boolean }` + * + * - `selected`: indicating whether item is selected or not. + * - `index`: item's index. + * - `visible`: indicating whether item is visible in viewport or not. Unless initiallyVisibleItemCount was given, + this value is always true. + */ + elementForItem: ( + item: object | string, + options: { selected: boolean; index: number; visible: boolean } + ) => EtchElement // TODO: HTMLElement + + /** (Optional) the number of maximum items that are shown. */ + maxResults?: number + + /** (Optional) a function that allows to decide which items to show whenever the query changes. + By default, it uses fuzzaldrin to filter results. */ + filter?: (items: Array, query: string) => Array + + /** (Optional) when filter is not provided, this function will be called to retrieve a string property on each item, + and that will be used to filter them. */ + filterKeyForItem?: (item: object | string) => string + + /** (Optional) a function that allows to apply a transformation to the user query and whose return value + will be used to filter items. */ + filterQuery?: (query: string) => string + + /** (Optional) a string that will replace the contents of the query editor. */ + query?: string + + /** (Optional) a boolean indicating whether the query text should be selected or not. */ + selectQuery?: boolean + + /** (Optional) a function that allows to change the order in which items are shown. */ + order?: (item1: object | string, item2: object | string) => number + + /** (Optional) a string shown when the list is empty. */ + emptyMessage?: string + + /** (Optional) a string that needs to be set when you want to notify the user that an error occurred. */ + errorMessage?: string + + /** (Optional) a string that needs to be set when you want to provide some information to the user. */ + infoMessage?: string + + /** (Optional) a string that needs to be set when you are loading items in the background. */ + loadingMessage?: string + + /** (Optional) a string or number that needs to be set when the progress status changes + (e.g. a percentage showing how many items have been loaded so far). */ + loadingBadge?: string | number + + /** (Optional) an array of strings that will be added as class names to the items element. */ + itemsClassList?: Array + + /** (Optional) the index of the item to initially select and automatically select after query changes; defaults to 0. */ + initialSelectionIndex?: number + + /** (Optional) a function that is called when the query changes. */ + didChangeQuery?: (query: string) => void + + /** (Optional) a function that is called when the selected item changes. */ + didChangeSelection?: (item: object | string) => void + + /** (Optional) a function that is called when the user clicks or presses Enter on an item. */ + didConfirmSelection?: (item: object | string) => void + + /** (Optional) a function that is called when the user presses Enter but the list is empty. */ + didConfirmEmptySelection?: () => void + + /** (Optional) a function that is called when the user presses Esc or the list loses focus. */ + didCancelSelection?: () => void + + /** (Optional) When this options was provided, SelectList observe visibility of items in viewport, visibility state is + passed as visible option to elementForItem. This is mainly used to skip heavy computation for invisible items. */ + initiallyVisibleItemCount?: number + + skipCommandsRegistration?: boolean +} diff --git a/src/select-list-view.js b/src/select-list-view.ts similarity index 79% rename from src/select-list-view.js rename to src/select-list-view.ts index f55d038..8a9bf72 100644 --- a/src/select-list-view.js +++ b/src/select-list-view.ts @@ -1,18 +1,39 @@ -const {Disposable, CompositeDisposable, TextEditor} = require('atom') -const etch = require('etch') +import { Disposable, CompositeDisposable, TextEditor, CommandEvent } from 'atom' +// @ts-ignore Merge https://github.com/atom/etch/pull/90 +import etch from 'etch' const $ = etch.dom -const fuzzaldrin = require('fuzzaldrin') +import fuzzaldrin from 'fuzzaldrin' + +export type EtchElement = HTMLElement +type EtchScheduler = any + +import { SelectListProperties } from './select-list-properties' module.exports = class SelectListView { - static setScheduler (scheduler) { + /** When creating a new instance of a select list, or when calling `update` on an existing one, + you can supply an object with the typeof SelectListProperties */ + props: SelectListProperties + + /** An array containing the filtered and ordered items to be shown in the select list. */ + private items: Array + + private disposables: CompositeDisposable + private element: EtchElement + private didClickItemsList: boolean + private visibilityObserver: IntersectionObserver + private listItems: any[] | null + private selectionIndex: number | undefined + private refs: any; + + static setScheduler (scheduler: EtchScheduler) { etch.setScheduler(scheduler) } - static getScheduler (scheduler) { + static getScheduler () { return etch.getScheduler() } - constructor (props) { + constructor (props: SelectListProperties) { this.props = props if (!this.props.hasOwnProperty('initialSelectionIndex')) { this.props.initialSelectionIndex = 0 @@ -49,9 +70,9 @@ module.exports = class SelectListView { this.visibilityObserver = new IntersectionObserver(changes => { for (const change of changes) { if (change.intersectionRatio > 0) { - const element = change.target + const element = change.target as EtchElement this.visibilityObserver.unobserve(element) - const index = Array.from(this.refs.items.children).indexOf(element) + const index = Array.from(this.refs.items.children as EtchElement[]).indexOf(element) if (index >= 0) { this.renderItemAtIndex(index) } @@ -64,7 +85,7 @@ module.exports = class SelectListView { this.refs.queryEditor.element.focus() } - didLoseFocus (event) { + didLoseFocus (event: {relatedTarget: Node}) { if (this.didClickItemsList || this.element.contains(event.relatedTarget)) { this.didClickItemsList = false this.refs.queryEditor.element.focus() @@ -84,65 +105,65 @@ module.exports = class SelectListView { } registerAtomCommands () { - return global.atom.commands.add(this.element, { - 'core:move-up': (event) => { + return atom.commands.add(this.element, { + 'core:move-up': (event: CommandEvent) => { this.selectPrevious() event.stopPropagation() }, - 'core:move-down': (event) => { + 'core:move-down': (event: CommandEvent) => { this.selectNext() event.stopPropagation() }, - 'core:move-to-top': (event) => { + 'core:move-to-top': (event: CommandEvent) => { this.selectFirst() event.stopPropagation() }, - 'core:move-to-bottom': (event) => { + 'core:move-to-bottom': (event: CommandEvent) => { this.selectLast() event.stopPropagation() }, - 'core:confirm': (event) => { + 'core:confirm': (event: CommandEvent) => { this.confirmSelection() event.stopPropagation() }, - 'core:cancel': (event) => { + 'core:cancel': (event: CommandEvent) => { this.cancelSelection() event.stopPropagation() } }) } - update (props = {}) { + update (props: SelectListProperties) { let shouldComputeItems = false - if (props.hasOwnProperty('items')) { + if ('items' in props) { this.props.items = props.items shouldComputeItems = true } - if (props.hasOwnProperty('maxResults')) { + if ('maxResults' in props) { this.props.maxResults = props.maxResults shouldComputeItems = true } - if (props.hasOwnProperty('filter')) { + if ('filter' in props) { this.props.filter = props.filter shouldComputeItems = true } - if (props.hasOwnProperty('filterQuery')) { + if ('filterQuery' in props) { this.props.filterQuery = props.filterQuery shouldComputeItems = true } - if (props.hasOwnProperty('query')) { + if ('query' in props) { // Items will be recomputed as part of the change event handler, so we // don't need to recompute them again at the end of this function. this.refs.queryEditor.setText(props.query) shouldComputeItems = false } - if (props.hasOwnProperty('selectQuery')) { + if ('selectQuery' in props) { if (props.selectQuery) { this.refs.queryEditor.selectAll() } else { @@ -150,35 +171,35 @@ module.exports = class SelectListView { } } - if (props.hasOwnProperty('order')) { + if ('order' in props) { this.props.order = props.order } - if (props.hasOwnProperty('emptyMessage')) { + if ('emptyMessage' in props) { this.props.emptyMessage = props.emptyMessage } - if (props.hasOwnProperty('errorMessage')) { + if ('errorMessage' in props) { this.props.errorMessage = props.errorMessage } - if (props.hasOwnProperty('infoMessage')) { + if ('infoMessage' in props) { this.props.infoMessage = props.infoMessage } - if (props.hasOwnProperty('loadingMessage')) { + if ('loadingMessage' in props) { this.props.loadingMessage = props.loadingMessage } - if (props.hasOwnProperty('loadingBadge')) { + if ('loadingBadge' in props) { this.props.loadingBadge = props.loadingBadge } - if (props.hasOwnProperty('itemsClassList')) { + if ('itemsClassList' in props) { this.props.itemsClassList = props.itemsClassList } - if (props.hasOwnProperty('initialSelectionIndex')) { + if ('initialSelectionIndex' in props) { this.props.initialSelectionIndex = props.initialSelectionIndex } @@ -206,7 +227,7 @@ module.exports = class SelectListView { if (this.visibilityObserver) { etch.getScheduler().updateDocument(() => { - Array.from(this.refs.items.children).slice(this.props.initiallyVisibleItemCount).forEach(element => { + Array.from(this.refs.items.children as EtchElement[]).slice(this.props.initiallyVisibleItemCount).forEach((element) => { this.visibilityObserver.observe(element) }) }) @@ -281,15 +302,16 @@ module.exports = class SelectListView { this.computeItems() } - didClickItem (itemIndex) { + didClickItem (itemIndex: number) { this.selectIndex(itemIndex) this.confirmSelection() } - computeItems (updateComponent) { + computeItems (updateComponent?: boolean) { this.listItems = null if (this.visibilityObserver) this.visibilityObserver.disconnect() const filterFn = this.props.filter || this.fuzzyFilter.bind(this) + // @ts-ignore fuzzaldrin types should be fixed this.items = filterFn(this.props.items.slice(), this.getFilterQuery()) if (this.props.order) { this.items.sort(this.props.order) @@ -301,14 +323,14 @@ module.exports = class SelectListView { this.selectIndex(this.props.initialSelectionIndex, updateComponent) } - fuzzyFilter (items, query) { + fuzzyFilter (items: Array, query?: string) { if (query.length === 0) { return items } else { const scoredItems = [] for (const item of items) { const string = this.props.filterKeyForItem ? this.props.filterKeyForItem(item) : item - let score = fuzzaldrin.score(string, query) + const score = fuzzaldrin.score(string, query) if (score > 0) { scoredItems.push({item, score}) } @@ -323,7 +345,7 @@ module.exports = class SelectListView { return this.items[this.selectionIndex] } - renderItemAtIndex (index) { + renderItemAtIndex (index: number) { const item = this.items[index] const selected = this.getSelectedItem() === item const component = this.listItems[index].component @@ -357,7 +379,7 @@ module.exports = class SelectListView { return this.selectIndex(undefined) } - selectIndex (index, updateComponent = true) { + selectIndex (index: number, updateComponent = true) { if (index >= this.items.length) { index = 0 } else if (index < 0) { @@ -384,7 +406,7 @@ module.exports = class SelectListView { } } - selectItem (item) { + selectItem (item: object | string) { const index = this.items.indexOf(item) if (index === -1) { throw new Error('Cannot select the specified item because it does not exist.') @@ -413,8 +435,15 @@ module.exports = class SelectListView { } } +type ListItemViewProps = { element: EtchElement ; selected: boolean; onclick: () => void } + class ListItemView { - constructor (props) { + public element: EtchElement + public selected: boolean + public onclick: () => void + public domEventsDisposable: Disposable + + constructor (props: ListItemViewProps) { this.mouseDown = this.mouseDown.bind(this) this.mouseUp = this.mouseUp.bind(this) this.didClick = this.didClick.bind(this) @@ -435,15 +464,15 @@ class ListItemView { etch.getScheduler().updateDocument(this.scrollIntoViewIfNeeded.bind(this)) } - mouseDown (event) { + mouseDown (event: MouseEvent) { event.preventDefault() } - mouseUp (event) { + mouseUp (event: MouseEvent) { event.preventDefault() } - didClick (event) { + didClick (event: MouseEvent) { event.preventDefault() this.onclick() } @@ -453,7 +482,7 @@ class ListItemView { this.domEventsDisposable.dispose() } - update (props) { + update (props: ListItemViewProps) { this.element.removeEventListener('mousedown', this.mouseDown) this.element.removeEventListener('mouseup', this.mouseUp) this.element.removeEventListener('click', this.didClick) @@ -474,6 +503,7 @@ class ListItemView { scrollIntoViewIfNeeded () { if (this.selected) { + // @ts-ignore: this function is a non-standard API. this.element.scrollIntoViewIfNeeded(false) } } diff --git a/test/select-list-view.test.js b/test/select-list-view.test.js index 6d45d23..df26db6 100644 --- a/test/select-list-view.test.js +++ b/test/select-list-view.test.js @@ -4,7 +4,7 @@ const assert = require('assert') const etch = require('etch') const sinon = require('sinon') const sandbox = sinon.sandbox.create() -const SelectListView = require('../src/select-list-view') +const SelectListView = require('../lib/select-list-view') describe('SelectListView', () => { let containerNode = null diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2795896 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "strict": true, + "strictNullChecks": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "incremental": true, + "sourceMap": true, + "inlineSources": true, + "removeComments": false, + "jsx": "react", + "jsxFactory": "etch.dom", + "lib": ["ES2018", "dom"], + "target": "ES2018", + "allowJs": true, + "esModuleInterop": true, + "module": "commonjs", + "moduleResolution": "node", + "outDir": "lib" + }, + "include": [ + "src" + ] +}