pulsar-select-list 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +7 -0
- package/README.md +444 -0
- package/package.json +32 -0
- package/select.js +806 -0
- package/select.less +53 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2011-2017 GitHub Inc.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# pulsar-select-list
|
|
2
|
+
|
|
3
|
+
This module is an [etch component](https://github.com/atom/etch) that can be used in Pulsar packages to show a select list with fuzzy filtering, keyboard/mouse navigation and other cool features.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"pulsar-select-list": "^1.0.0"
|
|
10
|
+
}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
After installing the module, you can simply require it and use it as a standalone component:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const SelectListView = require('pulsar-select-list')
|
|
19
|
+
|
|
20
|
+
const usersSelectList = new SelectListView({
|
|
21
|
+
items: ['Alice', 'Bob', 'Carol'],
|
|
22
|
+
elementForItem: (item) => {
|
|
23
|
+
const li = document.createElement('li')
|
|
24
|
+
li.textContent = item
|
|
25
|
+
return li
|
|
26
|
+
},
|
|
27
|
+
didConfirmSelection: (item) => {
|
|
28
|
+
console.log('Selected:', item)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Show as modal panel
|
|
33
|
+
usersSelectList.show()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or within another etch component:
|
|
37
|
+
|
|
38
|
+
```jsx
|
|
39
|
+
render () {
|
|
40
|
+
return (
|
|
41
|
+
<SelectListView items={this.items} />
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
### Constructor Props
|
|
49
|
+
|
|
50
|
+
When creating a new instance of a select list, or when calling `update` on an existing one, you can supply a JavaScript object that can contain any of the following properties:
|
|
51
|
+
|
|
52
|
+
#### Required
|
|
53
|
+
|
|
54
|
+
* `items: [Object]`: an array containing the objects you want to show in the select list.
|
|
55
|
+
* `elementForItem: (item: Object, options: Object) -> HTMLElement`: a function that is called whenever an item needs to be displayed.
|
|
56
|
+
* `options: Object`:
|
|
57
|
+
* `selected: Boolean`: indicating whether item is selected or not.
|
|
58
|
+
* `index: Number`: item's index.
|
|
59
|
+
* `visible: Boolean`: indicating whether item is visible in viewport or not.
|
|
60
|
+
|
|
61
|
+
#### Optional
|
|
62
|
+
|
|
63
|
+
* `className: String`: CSS class name(s) to add to the select list element. Multiple classes can be space-separated.
|
|
64
|
+
* `maxResults: Number`: the number of maximum items that are shown.
|
|
65
|
+
* `filter: (items: [Object], query: String) -> [Object]`: a function that allows to decide which items to show whenever the query changes. By default, it uses Pulsar's built-in fuzzy matcher.
|
|
66
|
+
* `filterKeyForItem: (item: Object) -> String`: 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.
|
|
67
|
+
* `filterQuery: (query: String) -> String`: a function that allows to apply a transformation to the user query and whose return value will be used to filter items.
|
|
68
|
+
* `replaceDiacritics: Boolean`: when `true` (default), removes diacritical marks from both the query and item text before filtering, enabling accent-insensitive matching (e.g., "cafe" matches "café"). Set to `false` to disable.
|
|
69
|
+
* `filterScoreModifier: (score: Number, item: Object) -> Number`: a function to modify the fuzzy match score for each item. Useful for applying custom ranking factors (e.g., boosting by recency or proximity).
|
|
70
|
+
* `filterThreshold: Number`: minimum score required for an item to be included in results; defaults to `0`. Scores at or below this threshold are filtered out.
|
|
71
|
+
* `query: String`: a string that will replace the contents of the query editor.
|
|
72
|
+
* `selectQuery: Boolean`: a boolean indicating whether the query text should be selected or not.
|
|
73
|
+
* `order: (item1: Object, item2: Object) -> Number`: a function that allows to change the order in which items are shown.
|
|
74
|
+
* `emptyMessage: String`: a string shown when the list is empty.
|
|
75
|
+
* `errorMessage: String`: a string that needs to be set when you want to notify the user that an error occurred.
|
|
76
|
+
* `infoMessage: String`: a string that needs to be set when you want to provide some information to the user.
|
|
77
|
+
* `helpMessage: String|Array`: content to display when help is toggled. Use `toggleHelp()` to show/hide. Can be a string or JSX array for rich formatting.
|
|
78
|
+
* `helpMarkdown: String`: markdown content to display when help is toggled. Rendered using Pulsar's built-in markdown renderer. Preferred over `helpMessage` for simple help text stored in `.md` files.
|
|
79
|
+
* `loadingMessage: String`: a string that needs to be set when you are loading items in the background.
|
|
80
|
+
* `loadingBadge: String/Number`: a string or number that needs to be set when the progress status changes.
|
|
81
|
+
* `itemsClassList: [String]`: an array of strings that will be added as class names to the items element.
|
|
82
|
+
* `initialSelectionIndex: Number`: the index of the item to initially select; defaults to `0`.
|
|
83
|
+
* `initiallyVisibleItemCount: Number`: When provided, `SelectListView` observes visibility of items in viewport, visibility state is passed as `visible` option to `elementForItem`.
|
|
84
|
+
* `placeholderText: String`: placeholder text to display in the query editor when empty.
|
|
85
|
+
* `skipCommandsRegistration: Boolean`: when `true`, skips registering default keyboard commands.
|
|
86
|
+
|
|
87
|
+
### Registered Commands
|
|
88
|
+
|
|
89
|
+
By default, the component registers these commands on its element:
|
|
90
|
+
|
|
91
|
+
* `core:move-up` / `core:move-down`: Navigate items
|
|
92
|
+
* `core:move-to-top` / `core:move-to-bottom`: Jump to first/last item
|
|
93
|
+
* `core:confirm`: Confirm selection
|
|
94
|
+
* `core:cancel`: Cancel selection
|
|
95
|
+
* `select-list:help`: Toggle help message visibility (requires `helpMessage` or `helpMarkdown`)
|
|
96
|
+
|
|
97
|
+
#### Callbacks
|
|
98
|
+
|
|
99
|
+
* `didChangeQuery: (query: String) -> Void`: called when the query changes.
|
|
100
|
+
* `didChangeSelection: (item: Object) -> Void`: called when the selected item changes.
|
|
101
|
+
* `didConfirmSelection: (item: Object) -> Void`: called when the user clicks or presses Enter on an item.
|
|
102
|
+
* `didConfirmEmptySelection: () -> Void`: called when the user presses Enter but the list is empty.
|
|
103
|
+
* `didCancelSelection: () -> Void`: called when the user presses Esc or the list loses focus.
|
|
104
|
+
* `willShow: () -> Void`: called when transitioning from hidden to visible, useful for data preparation.
|
|
105
|
+
|
|
106
|
+
### Instance Properties
|
|
107
|
+
|
|
108
|
+
* `processedQuery: String`: The cached result of `getFilterQuery()`, updated after each query change. Useful in `elementForItem` to avoid calling `getFilterQuery()` multiple times.
|
|
109
|
+
* `refs.queryEditor`: The underlying TextEditor component for the query input.
|
|
110
|
+
|
|
111
|
+
### Instance Methods
|
|
112
|
+
|
|
113
|
+
#### Panel Management
|
|
114
|
+
|
|
115
|
+
* `show()`: Shows the select list as a modal panel and focuses the query editor. Calls `willShow` callback if provided.
|
|
116
|
+
* `hide()`: Hides the panel and restores focus to the previously focused element.
|
|
117
|
+
* `toggle()`: Toggles the visibility of the panel.
|
|
118
|
+
* `isVisible()`: Returns `true` if the panel is currently visible.
|
|
119
|
+
* `isHelpMode()`: Returns `true` if help is currently displayed.
|
|
120
|
+
* `toggleHelp()`: Toggles help message visibility. Only works if `helpMessage` is set.
|
|
121
|
+
* `hideHelp()`: Hides help message if currently shown.
|
|
122
|
+
|
|
123
|
+
#### Other Methods
|
|
124
|
+
|
|
125
|
+
* `focus()`: Focuses the query editor.
|
|
126
|
+
* `reset()`: Clears the query editor text.
|
|
127
|
+
* `destroy()`: Disposes of the component and cleans up resources.
|
|
128
|
+
* `update(props)`: Updates the component with new props.
|
|
129
|
+
* `getQuery()`: Returns the current query string.
|
|
130
|
+
* `getFilterQuery()`: Returns the filtered query string (applies `filterQuery` transformation).
|
|
131
|
+
* `setQueryFromSelection()`: Sets the query text from the active editor's selection. Returns `true` if successful, `false` if no editor, no selection, or selection contains newlines.
|
|
132
|
+
* `getSelectedItem()`: Returns the currently selected item.
|
|
133
|
+
* `selectPrevious()`: Selects the previous item.
|
|
134
|
+
* `selectNext()`: Selects the next item.
|
|
135
|
+
* `selectFirst()`: Selects the first item.
|
|
136
|
+
* `selectLast()`: Selects the last item.
|
|
137
|
+
* `selectNone()`: Deselects all items.
|
|
138
|
+
* `selectIndex(index)`: Selects the item at the given index.
|
|
139
|
+
* `selectItem(item)`: Selects the given item.
|
|
140
|
+
* `confirmSelection()`: Confirms the current selection.
|
|
141
|
+
* `cancelSelection()`: Cancels the selection.
|
|
142
|
+
|
|
143
|
+
### Static Methods
|
|
144
|
+
|
|
145
|
+
#### `SelectListView.highlightMatches(text, matchIndices, options)`
|
|
146
|
+
|
|
147
|
+
Creates a DocumentFragment with highlighted match characters.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const matches = atom.ui.fuzzyMatcher.match(query, text, {
|
|
151
|
+
recordMatchIndexes: true
|
|
152
|
+
}).matchIndexes
|
|
153
|
+
|
|
154
|
+
const fragment = SelectListView.highlightMatches(text, matches)
|
|
155
|
+
element.appendChild(fragment)
|
|
156
|
+
|
|
157
|
+
// With custom class name
|
|
158
|
+
const fragment = SelectListView.highlightMatches(text, matches, {
|
|
159
|
+
className: 'my-highlight'
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
* `text: String`: the text to highlight.
|
|
164
|
+
* `matchIndices: [Number]`: array of character indices to highlight.
|
|
165
|
+
* `options: Object` (optional):
|
|
166
|
+
* `className: String`: CSS class for highlighted spans; defaults to `'character-match'`.
|
|
167
|
+
|
|
168
|
+
Returns a `DocumentFragment` containing text nodes and `<span>` elements with the specified class.
|
|
169
|
+
|
|
170
|
+
#### `SelectListView.replaceDiacritics(str)`
|
|
171
|
+
|
|
172
|
+
Removes diacritical marks (accents) from a string.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
SelectListView.replaceDiacritics('café') // => 'cafe'
|
|
176
|
+
SelectListView.replaceDiacritics('naïve') // => 'naive'
|
|
177
|
+
SelectListView.replaceDiacritics('Müller') // => 'Muller'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
* `str: String`: the string to process.
|
|
181
|
+
|
|
182
|
+
Returns the string with diacritical marks removed. Uses `String.normalize('NFD')` internally.
|
|
183
|
+
|
|
184
|
+
#### `SelectListView.createTwoLineItem(options)`
|
|
185
|
+
|
|
186
|
+
Creates a two-line list item element with primary and optional secondary lines. This is a convenience helper for the common Atom/Pulsar two-line item pattern.
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
const li = SelectListView.createTwoLineItem({
|
|
190
|
+
primary: SelectListView.highlightMatches(item.name, matches),
|
|
191
|
+
secondary: item.description,
|
|
192
|
+
icon: ['icon-file-text']
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
* `options: Object`:
|
|
197
|
+
* `primary: String|Node`: Primary line content (text string or DOM node)
|
|
198
|
+
* `secondary: String|Node` (optional): Secondary line content
|
|
199
|
+
* `icon: [String]` (optional): Icon class names to add to primary line (adds `icon` class automatically)
|
|
200
|
+
|
|
201
|
+
Returns an `HTMLLIElement` with the structure:
|
|
202
|
+
```html
|
|
203
|
+
<li class="two-lines">
|
|
204
|
+
<div class="primary-line icon [icon]">[primary]</div>
|
|
205
|
+
<div class="secondary-line">[secondary]</div>
|
|
206
|
+
</li>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### `SelectListView.setScheduler(scheduler)`
|
|
210
|
+
|
|
211
|
+
Sets the etch scheduler.
|
|
212
|
+
|
|
213
|
+
#### `SelectListView.getScheduler()`
|
|
214
|
+
|
|
215
|
+
Gets the current etch scheduler.
|
|
216
|
+
|
|
217
|
+
## Example
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
const SelectListView = require('pulsar-select-list')
|
|
221
|
+
const fs = require('fs')
|
|
222
|
+
const path = require('path')
|
|
223
|
+
|
|
224
|
+
class MyFileList {
|
|
225
|
+
constructor() {
|
|
226
|
+
this.selectList = new SelectListView({
|
|
227
|
+
className: 'my-package my-file-list',
|
|
228
|
+
items: [],
|
|
229
|
+
filterKeyForItem: (item) => item.name,
|
|
230
|
+
emptyMessage: 'No files found',
|
|
231
|
+
helpMarkdown: fs.readFileSync(path.join(__dirname, 'help.md'), 'utf8'),
|
|
232
|
+
|
|
233
|
+
willShow: () => {
|
|
234
|
+
this.previouslyFocusedElement = document.activeElement
|
|
235
|
+
this.loadFiles()
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
elementForItem: (item, options) => {
|
|
239
|
+
const li = document.createElement('li')
|
|
240
|
+
if (!options.visible) {
|
|
241
|
+
return li
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const query = this.selectList.processedQuery || ''
|
|
245
|
+
const matches = query
|
|
246
|
+
? atom.ui.fuzzyMatcher.match(item.name, query, {
|
|
247
|
+
recordMatchIndexes: true
|
|
248
|
+
}).matchIndexes
|
|
249
|
+
: []
|
|
250
|
+
|
|
251
|
+
li.appendChild(SelectListView.highlightMatches(item.name, matches))
|
|
252
|
+
|
|
253
|
+
li.addEventListener('contextmenu', () => {
|
|
254
|
+
this.selectList.selectIndex(options.index)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return li
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
didConfirmSelection: (item) => {
|
|
261
|
+
atom.workspace.open(item.path)
|
|
262
|
+
this.selectList.hide()
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
didCancelSelection: () => {
|
|
266
|
+
this.selectList.hide()
|
|
267
|
+
},
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
loadFiles() {
|
|
272
|
+
// Load files and update items
|
|
273
|
+
this.selectList.update({ items: this.files })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
toggle() {
|
|
277
|
+
this.selectList.toggle()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
destroy() {
|
|
281
|
+
this.selectList.destroy()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Advanced: Custom Score Modifier
|
|
287
|
+
|
|
288
|
+
Use `filterScoreModifier` and `filterThreshold` to customize ranking:
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
const selectList = new SelectListView({
|
|
292
|
+
items: files,
|
|
293
|
+
elementForItem: (item) => {
|
|
294
|
+
const li = document.createElement('li')
|
|
295
|
+
li.textContent = item.path
|
|
296
|
+
return li
|
|
297
|
+
},
|
|
298
|
+
filterKeyForItem: (item) => item.path,
|
|
299
|
+
// Boost score by proximity (items closer to current file rank higher)
|
|
300
|
+
filterScoreModifier: (score, item) => score / item.distance,
|
|
301
|
+
// Only show items with meaningful scores
|
|
302
|
+
filterThreshold: 0.01,
|
|
303
|
+
didConfirmSelection: (item) => {
|
|
304
|
+
atom.workspace.open(item.path)
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Migration from atom-select-list
|
|
310
|
+
|
|
311
|
+
If you're migrating from `atom-select-list`, here are the key changes:
|
|
312
|
+
|
|
313
|
+
### Package.json
|
|
314
|
+
|
|
315
|
+
```diff
|
|
316
|
+
"dependencies": {
|
|
317
|
+
- "atom-select-list": "^0.8.1",
|
|
318
|
+
+ "pulsar-select-list": "^1.0.0"
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Import
|
|
323
|
+
|
|
324
|
+
```diff
|
|
325
|
+
-const SelectListView = require('atom-select-list')
|
|
326
|
+
+const SelectListView = require('pulsar-select-list')
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Panel Management
|
|
330
|
+
|
|
331
|
+
The component now manages its own panel. Remove custom panel handling:
|
|
332
|
+
|
|
333
|
+
```diff
|
|
334
|
+
-this.panel = null
|
|
335
|
+
-this.previouslyFocusedElement = null
|
|
336
|
+
|
|
337
|
+
this.slv = new SelectListView({
|
|
338
|
+
+ className: 'my-list',
|
|
339
|
+
items: [],
|
|
340
|
+
+ willShow: () => this.onWillShow(),
|
|
341
|
+
// ...
|
|
342
|
+
})
|
|
343
|
+
-this.slv.element.classList.add('my-list')
|
|
344
|
+
|
|
345
|
+
-showView() {
|
|
346
|
+
- this.previouslyFocusedElement = document.activeElement
|
|
347
|
+
- if (!this.panel) {
|
|
348
|
+
- this.panel = atom.workspace.addModalPanel({ item: this.slv })
|
|
349
|
+
- }
|
|
350
|
+
- this.panel.show()
|
|
351
|
+
- this.slv.focus()
|
|
352
|
+
-}
|
|
353
|
+
-
|
|
354
|
+
-hideView() {
|
|
355
|
+
- this.panel.hide()
|
|
356
|
+
- if (this.previouslyFocusedElement) {
|
|
357
|
+
- this.previouslyFocusedElement.focus()
|
|
358
|
+
- }
|
|
359
|
+
-}
|
|
360
|
+
-
|
|
361
|
+
-toggleView() {
|
|
362
|
+
- if (this.panel && this.panel.isVisible()) {
|
|
363
|
+
- this.hideView()
|
|
364
|
+
- } else {
|
|
365
|
+
- this.showView()
|
|
366
|
+
- }
|
|
367
|
+
-}
|
|
368
|
+
|
|
369
|
+
// Use built-in methods:
|
|
370
|
+
-this.toggleView()
|
|
371
|
+
+this.slv.toggle()
|
|
372
|
+
|
|
373
|
+
-this.hideView()
|
|
374
|
+
+this.slv.hide()
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Diacritics
|
|
378
|
+
|
|
379
|
+
Replace external diacritics library with built-in static method:
|
|
380
|
+
|
|
381
|
+
```diff
|
|
382
|
+
-const Diacritics = require('diacritic')
|
|
383
|
+
|
|
384
|
+
-Diacritics.clean(text)
|
|
385
|
+
+SelectListView.replaceDiacritics(text)
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Query Access
|
|
389
|
+
|
|
390
|
+
Use `processedQuery` instead of storing query in filter:
|
|
391
|
+
|
|
392
|
+
```diff
|
|
393
|
+
filter(items, query) {
|
|
394
|
+
- this.query = query
|
|
395
|
+
+ // query is passed to filter, use this.slv.processedQuery in elementForItem
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
elementForItem(item, options) {
|
|
399
|
+
- const query = this.query
|
|
400
|
+
+ const query = this.slv.processedQuery || ''
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Highlighting
|
|
405
|
+
|
|
406
|
+
Use built-in `highlightMatches` static method:
|
|
407
|
+
|
|
408
|
+
```diff
|
|
409
|
+
-this.highlightInElement(el, text, indices)
|
|
410
|
+
+el.appendChild(SelectListView.highlightMatches(text, indices))
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
### Help Message
|
|
415
|
+
|
|
416
|
+
Replace `infoMessage` with `helpMarkdown` and the built-in `select-list:help` command:
|
|
417
|
+
|
|
418
|
+
```diff
|
|
419
|
+
this.slv = new SelectListView({
|
|
420
|
+
- // No help by default
|
|
421
|
+
+ helpMarkdown: fs.readFileSync(path.join(__dirname, 'help.md'), 'utf8'),
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
-atom.config.observe('my-package.showKeystrokes', (value) => {
|
|
425
|
+
- this.slv.update({ infoMessage: value ? [...] : null })
|
|
426
|
+
-})
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Create a `help.md` file with your help content:
|
|
430
|
+
|
|
431
|
+
```markdown
|
|
432
|
+
- **Enter** — Confirm selection
|
|
433
|
+
- **Alt+Enter** — Alternative action
|
|
434
|
+
- **F12** — Toggle this help
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Add F12 keybinding to toggle help (keymaps/my-package.cson):
|
|
438
|
+
|
|
439
|
+
```cson
|
|
440
|
+
'.my-list atom-text-editor[mini]':
|
|
441
|
+
'f12': 'select-list:help'
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
The `select-list:help` command is registered automatically by the component.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pulsar-select-list",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A general-purpose select list for use in Pulsar packages",
|
|
5
|
+
"main": "select.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pulsar",
|
|
9
|
+
"atom",
|
|
10
|
+
"select-list",
|
|
11
|
+
"fuzzy",
|
|
12
|
+
"filter",
|
|
13
|
+
"ui"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"select.js",
|
|
17
|
+
"select.less",
|
|
18
|
+
"LICENSE.md",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"etch": "^0.14.0"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/asiloisad/pulsar-select-list.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/asiloisad/pulsar-select-list/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/asiloisad/pulsar-select-list#readme"
|
|
32
|
+
}
|
package/select.js
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Disposable, CompositeDisposable, TextEditor } = require('atom')
|
|
4
|
+
const etch = require('etch')
|
|
5
|
+
const $ = etch.dom
|
|
6
|
+
|
|
7
|
+
class SelectListView {
|
|
8
|
+
static setScheduler(scheduler) {
|
|
9
|
+
etch.setScheduler(scheduler)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static getScheduler() {
|
|
13
|
+
return etch.getScheduler()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static replaceDiacritics(str) {
|
|
17
|
+
if (!str) return ''
|
|
18
|
+
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static highlightMatches(text, matchIndices, options = {}) {
|
|
22
|
+
const { className = 'character-match' } = options
|
|
23
|
+
const fragment = document.createDocumentFragment()
|
|
24
|
+
|
|
25
|
+
if (!matchIndices || matchIndices.length === 0) {
|
|
26
|
+
fragment.appendChild(document.createTextNode(text))
|
|
27
|
+
return fragment
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Filter out invalid indices (negative or out of range)
|
|
31
|
+
const validIndices = matchIndices.filter((i) => i >= 0 && i < text.length)
|
|
32
|
+
|
|
33
|
+
if (validIndices.length === 0) {
|
|
34
|
+
fragment.appendChild(document.createTextNode(text))
|
|
35
|
+
return fragment
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let lastIndex = 0
|
|
39
|
+
let matchChars = ''
|
|
40
|
+
|
|
41
|
+
for (const index of validIndices) {
|
|
42
|
+
if (index > lastIndex) {
|
|
43
|
+
if (matchChars) {
|
|
44
|
+
const span = document.createElement('span')
|
|
45
|
+
span.className = className
|
|
46
|
+
span.textContent = matchChars
|
|
47
|
+
fragment.appendChild(span)
|
|
48
|
+
matchChars = ''
|
|
49
|
+
}
|
|
50
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
|
|
51
|
+
}
|
|
52
|
+
matchChars += text[index]
|
|
53
|
+
lastIndex = index + 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (matchChars) {
|
|
57
|
+
const span = document.createElement('span')
|
|
58
|
+
span.className = className
|
|
59
|
+
span.textContent = matchChars
|
|
60
|
+
fragment.appendChild(span)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (lastIndex < text.length) {
|
|
64
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return fragment
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Creates a two-line list item element with primary and optional secondary lines.
|
|
72
|
+
* @param {Object} options - Configuration options
|
|
73
|
+
* @param {string|Node} options.primary - Primary line content (text or DOM node)
|
|
74
|
+
* @param {string|Node} [options.secondary] - Secondary line content (optional)
|
|
75
|
+
* @param {string[]} [options.icon] - Icon class names to add to primary line
|
|
76
|
+
* @returns {HTMLLIElement} The created list item element
|
|
77
|
+
*/
|
|
78
|
+
static createTwoLineItem({ primary, secondary, icon }) {
|
|
79
|
+
const li = document.createElement('li')
|
|
80
|
+
li.classList.add('two-lines')
|
|
81
|
+
|
|
82
|
+
const priLine = document.createElement('div')
|
|
83
|
+
priLine.classList.add('primary-line')
|
|
84
|
+
if (icon && icon.length > 0) {
|
|
85
|
+
priLine.classList.add('icon', ...icon)
|
|
86
|
+
}
|
|
87
|
+
if (typeof primary === 'string') {
|
|
88
|
+
priLine.textContent = primary
|
|
89
|
+
} else if (primary) {
|
|
90
|
+
priLine.appendChild(primary)
|
|
91
|
+
}
|
|
92
|
+
li.appendChild(priLine)
|
|
93
|
+
|
|
94
|
+
if (secondary !== undefined && secondary !== null) {
|
|
95
|
+
const secLine = document.createElement('div')
|
|
96
|
+
secLine.classList.add('secondary-line')
|
|
97
|
+
if (typeof secondary === 'string') {
|
|
98
|
+
secLine.textContent = secondary
|
|
99
|
+
} else {
|
|
100
|
+
secLine.appendChild(secondary)
|
|
101
|
+
}
|
|
102
|
+
li.appendChild(secLine)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return li
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
constructor(props) {
|
|
109
|
+
this.props = props
|
|
110
|
+
if (!this.props.hasOwnProperty('initialSelectionIndex')) {
|
|
111
|
+
this.props.initialSelectionIndex = 0
|
|
112
|
+
}
|
|
113
|
+
if (props.initiallyVisibleItemCount) {
|
|
114
|
+
this.initializeVisibilityObserver()
|
|
115
|
+
}
|
|
116
|
+
this.computeItems(false)
|
|
117
|
+
this.showHelp = false
|
|
118
|
+
this.helpMarkdownHtml = null
|
|
119
|
+
this.renderHelpMarkdownOnce()
|
|
120
|
+
this.disposables = new CompositeDisposable()
|
|
121
|
+
etch.initialize(this)
|
|
122
|
+
this.element.classList.add('select-list')
|
|
123
|
+
if (props.className) {
|
|
124
|
+
this.element.classList.add(...props.className.split(/\s+/).filter(Boolean))
|
|
125
|
+
}
|
|
126
|
+
this.disposables.add(this.refs.queryEditor.onDidChange(this.didChangeQuery.bind(this)))
|
|
127
|
+
this.disposables.add(this.refs.queryEditor.onWillInsertText((event) => {
|
|
128
|
+
if (event.text === '`') {
|
|
129
|
+
event.cancel()
|
|
130
|
+
this.toggleHelp()
|
|
131
|
+
}
|
|
132
|
+
}))
|
|
133
|
+
if (props.placeholderText) {
|
|
134
|
+
this.refs.queryEditor.setPlaceholderText(props.placeholderText)
|
|
135
|
+
}
|
|
136
|
+
if (!props.skipCommandsRegistration) {
|
|
137
|
+
this.disposables.add(this.registerAtomCommands())
|
|
138
|
+
}
|
|
139
|
+
const editorElement = this.refs.queryEditor.element
|
|
140
|
+
const didLoseFocus = this.didLoseFocus.bind(this)
|
|
141
|
+
editorElement.addEventListener('blur', didLoseFocus)
|
|
142
|
+
|
|
143
|
+
this.didClickInside = false
|
|
144
|
+
this.didMouseDown = () => {
|
|
145
|
+
this.didClickInside = true
|
|
146
|
+
}
|
|
147
|
+
this.element.addEventListener('mousedown', this.didMouseDown)
|
|
148
|
+
this.disposables.add(new Disposable(() => {
|
|
149
|
+
editorElement.removeEventListener('blur', didLoseFocus)
|
|
150
|
+
this.element.removeEventListener('mousedown', this.didMouseDown)
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
initializeVisibilityObserver() {
|
|
155
|
+
this.visibilityObserver = new IntersectionObserver(changes => {
|
|
156
|
+
for (const change of changes) {
|
|
157
|
+
if (change.intersectionRatio > 0) {
|
|
158
|
+
const element = change.target
|
|
159
|
+
this.visibilityObserver.unobserve(element)
|
|
160
|
+
const index = Array.from(this.refs.items.children).indexOf(element)
|
|
161
|
+
if (index >= 0) {
|
|
162
|
+
this.renderItemAtIndex(index)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
focus() {
|
|
170
|
+
this.refs.queryEditor.element.focus()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
didLoseFocus(event) {
|
|
174
|
+
if (this.didClickInside || this.element.contains(event.relatedTarget)) {
|
|
175
|
+
this.didClickInside = false
|
|
176
|
+
this.refs.queryEditor.element.focus()
|
|
177
|
+
} else if (document.hasFocus() && this.isVisible()) {
|
|
178
|
+
this.cancelSelection()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
reset() {
|
|
183
|
+
this.refs.queryEditor.setText('')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
destroy() {
|
|
187
|
+
this.disposables.dispose()
|
|
188
|
+
if (this.visibilityObserver) this.visibilityObserver.disconnect()
|
|
189
|
+
if (this.panel) {
|
|
190
|
+
this.panel.destroy()
|
|
191
|
+
this.panel = null
|
|
192
|
+
}
|
|
193
|
+
return etch.destroy(this)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
show() {
|
|
197
|
+
if (this.isVisible()) { return }
|
|
198
|
+
|
|
199
|
+
// Call willShow callback only when transitioning from hidden to visible
|
|
200
|
+
if (this.props.willShow) {
|
|
201
|
+
this.props.willShow()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Store previously focused element, but skip other select-list inputs
|
|
205
|
+
const active = document.activeElement
|
|
206
|
+
if (active && !active.closest('.select-list')) {
|
|
207
|
+
this.previouslyFocusedElement = active
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.refs.queryEditor.selectAll()
|
|
211
|
+
|
|
212
|
+
if (!this.panel) {
|
|
213
|
+
this.panel = atom.workspace.addModalPanel({ item: this, visible: false })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.panel.show()
|
|
217
|
+
this.focus()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
hide() {
|
|
221
|
+
if (!this.isVisible()) { return }
|
|
222
|
+
|
|
223
|
+
if (this.panel) {
|
|
224
|
+
this.panel.hide()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (this.previouslyFocusedElement) {
|
|
228
|
+
this.previouslyFocusedElement.focus()
|
|
229
|
+
this.previouslyFocusedElement = null
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
toggle() {
|
|
234
|
+
if (this.isVisible()) {
|
|
235
|
+
this.hide()
|
|
236
|
+
} else {
|
|
237
|
+
this.show()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
isVisible() {
|
|
242
|
+
return this.panel && this.panel.isVisible()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
registerAtomCommands() {
|
|
246
|
+
return atom.commands.add(this.element, {
|
|
247
|
+
'core:move-up': (event) => {
|
|
248
|
+
if (this.isHelpMode()) return
|
|
249
|
+
this.selectPrevious()
|
|
250
|
+
event.stopPropagation()
|
|
251
|
+
},
|
|
252
|
+
'core:move-down': (event) => {
|
|
253
|
+
if (this.isHelpMode()) return
|
|
254
|
+
this.selectNext()
|
|
255
|
+
event.stopPropagation()
|
|
256
|
+
},
|
|
257
|
+
'core:move-to-top': (event) => {
|
|
258
|
+
if (this.isHelpMode()) return
|
|
259
|
+
this.selectFirst()
|
|
260
|
+
event.stopPropagation()
|
|
261
|
+
},
|
|
262
|
+
'core:move-to-bottom': (event) => {
|
|
263
|
+
if (this.isHelpMode()) return
|
|
264
|
+
this.selectLast()
|
|
265
|
+
event.stopPropagation()
|
|
266
|
+
},
|
|
267
|
+
'core:confirm': (event) => {
|
|
268
|
+
this.confirmSelection()
|
|
269
|
+
event.stopPropagation()
|
|
270
|
+
},
|
|
271
|
+
'core:cancel': (event) => {
|
|
272
|
+
this.cancelSelection()
|
|
273
|
+
event.stopPropagation()
|
|
274
|
+
},
|
|
275
|
+
'select-list:help': (event) => {
|
|
276
|
+
this.toggleHelp()
|
|
277
|
+
event.stopPropagation()
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
update(props = {}) {
|
|
283
|
+
let shouldComputeItems = false
|
|
284
|
+
|
|
285
|
+
if ('items' in props) {
|
|
286
|
+
this.props.items = props.items
|
|
287
|
+
shouldComputeItems = true
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if ('maxResults' in props) {
|
|
291
|
+
this.props.maxResults = props.maxResults
|
|
292
|
+
shouldComputeItems = true
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if ('filter' in props) {
|
|
296
|
+
this.props.filter = props.filter
|
|
297
|
+
shouldComputeItems = true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if ('filterQuery' in props) {
|
|
301
|
+
this.props.filterQuery = props.filterQuery
|
|
302
|
+
shouldComputeItems = true
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if ('replaceDiacritics' in props) {
|
|
306
|
+
this.props.replaceDiacritics = props.replaceDiacritics
|
|
307
|
+
shouldComputeItems = true
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if ('filterKeyForItem' in props) {
|
|
311
|
+
this.props.filterKeyForItem = props.filterKeyForItem
|
|
312
|
+
shouldComputeItems = true
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if ('filterScoreModifier' in props) {
|
|
316
|
+
this.props.filterScoreModifier = props.filterScoreModifier
|
|
317
|
+
shouldComputeItems = true
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if ('filterThreshold' in props) {
|
|
321
|
+
this.props.filterThreshold = props.filterThreshold
|
|
322
|
+
shouldComputeItems = true
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if ('query' in props) {
|
|
326
|
+
this.refs.queryEditor.setText(props.query)
|
|
327
|
+
shouldComputeItems = false
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if ('selectQuery' in props) {
|
|
331
|
+
if (props.selectQuery) {
|
|
332
|
+
this.refs.queryEditor.selectAll()
|
|
333
|
+
} else {
|
|
334
|
+
this.refs.queryEditor.clearSelections()
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if ('order' in props) {
|
|
339
|
+
this.props.order = props.order
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if ('emptyMessage' in props) {
|
|
343
|
+
this.props.emptyMessage = props.emptyMessage
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if ('errorMessage' in props) {
|
|
347
|
+
this.props.errorMessage = props.errorMessage
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if ('infoMessage' in props) {
|
|
351
|
+
this.props.infoMessage = props.infoMessage
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if ('helpMessage' in props) {
|
|
355
|
+
this.props.helpMessage = props.helpMessage
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if ('helpMarkdown' in props) {
|
|
359
|
+
this.props.helpMarkdown = props.helpMarkdown
|
|
360
|
+
this.helpMarkdownHtml = null
|
|
361
|
+
this.renderHelpMarkdownOnce()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if ('loadingMessage' in props) {
|
|
365
|
+
this.props.loadingMessage = props.loadingMessage
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if ('loadingBadge' in props) {
|
|
369
|
+
this.props.loadingBadge = props.loadingBadge
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if ('itemsClassList' in props) {
|
|
373
|
+
this.props.itemsClassList = props.itemsClassList
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if ('initialSelectionIndex' in props) {
|
|
377
|
+
this.props.initialSelectionIndex = props.initialSelectionIndex
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if ('placeholderText' in props) {
|
|
381
|
+
this.props.placeholderText = props.placeholderText
|
|
382
|
+
this.refs.queryEditor.setPlaceholderText(props.placeholderText || '')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (shouldComputeItems) {
|
|
386
|
+
this.computeItems()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return etch.update(this)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
render() {
|
|
393
|
+
return $.div(
|
|
394
|
+
{},
|
|
395
|
+
this.renderQueryRow(),
|
|
396
|
+
this.renderLoadingMessage(),
|
|
397
|
+
this.renderInfoMessage(),
|
|
398
|
+
this.renderErrorMessage(),
|
|
399
|
+
this.renderHelpMessage(),
|
|
400
|
+
this.renderItems()
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
renderQueryRow() {
|
|
405
|
+
if (this.props.helpMessage || this.props.helpMarkdown) {
|
|
406
|
+
return $.div(
|
|
407
|
+
{ className: 'select-list-query-row' },
|
|
408
|
+
$(TextEditor, { ref: 'queryEditor', mini: true }),
|
|
409
|
+
$.span({
|
|
410
|
+
className: 'select-list-help-toggle icon-question',
|
|
411
|
+
on: { click: () => this.toggleHelp() }
|
|
412
|
+
})
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
return $(TextEditor, { ref: 'queryEditor', mini: true })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
renderItems() {
|
|
419
|
+
if (this.isHelpMode()) {
|
|
420
|
+
return ''
|
|
421
|
+
}
|
|
422
|
+
if (this.items.length > 0) {
|
|
423
|
+
const className = ['list-group'].concat(this.props.itemsClassList || []).join(' ')
|
|
424
|
+
|
|
425
|
+
if (this.visibilityObserver) {
|
|
426
|
+
etch.getScheduler().updateDocument(() => {
|
|
427
|
+
Array.from(this.refs.items.children).slice(this.props.initiallyVisibleItemCount).forEach((element) => {
|
|
428
|
+
this.visibilityObserver.observe(element)
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
this.listItems = this.items.map((item, index) => {
|
|
434
|
+
const selected = this.getSelectedItem() === item
|
|
435
|
+
const visible = !this.props.initiallyVisibleItemCount || index < this.props.initiallyVisibleItemCount
|
|
436
|
+
return $(ListItemView, {
|
|
437
|
+
element: this.props.elementForItem(item, { selected, index, visible }),
|
|
438
|
+
selected: selected,
|
|
439
|
+
onclick: () => this.didClickItem(index),
|
|
440
|
+
oncontextmenu: () => this.selectIndex(index)
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
return $.ol(
|
|
445
|
+
{ className, ref: 'items' },
|
|
446
|
+
...this.listItems
|
|
447
|
+
)
|
|
448
|
+
} else if (!this.props.loadingMessage && !this.isHelpMode() && this.props.emptyMessage) {
|
|
449
|
+
return $.div({ ref: 'emptyMessage', className: 'empty-message' }, this.props.emptyMessage)
|
|
450
|
+
} else {
|
|
451
|
+
return ""
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
renderErrorMessage() {
|
|
456
|
+
if (this.props.errorMessage) {
|
|
457
|
+
return $.div({ ref: 'errorMessage', className: 'error-message' }, this.props.errorMessage)
|
|
458
|
+
} else {
|
|
459
|
+
return ''
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
renderInfoMessage() {
|
|
464
|
+
if (this.props.infoMessage) {
|
|
465
|
+
return $.div({ ref: 'infoMessage', className: 'info-message' }, this.props.infoMessage)
|
|
466
|
+
} else {
|
|
467
|
+
return ''
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
renderLoadingMessage() {
|
|
472
|
+
if (this.props.loadingMessage) {
|
|
473
|
+
return $.div(
|
|
474
|
+
{ className: 'loading' },
|
|
475
|
+
$.div({ ref: 'loadingMessage', className: 'loading-message' }, this.props.loadingMessage),
|
|
476
|
+
$.span({ className: 'loading loading-spinner-tiny inline-block' }),
|
|
477
|
+
this.props.loadingBadge ? $.span({ ref: 'loadingBadge', className: 'badge' }, this.props.loadingBadge) : ''
|
|
478
|
+
)
|
|
479
|
+
} else {
|
|
480
|
+
return ''
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
renderHelpMessage() {
|
|
485
|
+
if (!this.showHelp) {
|
|
486
|
+
return ''
|
|
487
|
+
}
|
|
488
|
+
if (this.props.helpMarkdown) {
|
|
489
|
+
return $.div({ ref: 'helpMarkdownContainer', className: 'help-message' })
|
|
490
|
+
}
|
|
491
|
+
if (this.props.helpMessage) {
|
|
492
|
+
return $.div({ ref: 'helpMessage', className: 'help-message' }, this.props.helpMessage)
|
|
493
|
+
}
|
|
494
|
+
return ''
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
renderHelpMarkdownOnce() {
|
|
498
|
+
if (this.props.helpMarkdown && !this.helpMarkdownHtml) {
|
|
499
|
+
if (atom.ui && atom.ui.markdown && atom.ui.markdown.render) {
|
|
500
|
+
this.helpMarkdownHtml = atom.ui.markdown.render(this.props.helpMarkdown)
|
|
501
|
+
} else {
|
|
502
|
+
// Fallback: escape and wrap as text
|
|
503
|
+
const escaped = this.props.helpMarkdown.replace(/</g, '<').replace(/>/g, '>')
|
|
504
|
+
this.helpMarkdownHtml = `<p>${escaped}</p>`
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
updateHelpMarkdown() {
|
|
510
|
+
const container = this.element.querySelector('.help-message')
|
|
511
|
+
if (container && this.helpMarkdownHtml) {
|
|
512
|
+
container.innerHTML = this.helpMarkdownHtml
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
isHelpMode() {
|
|
517
|
+
return (this.props.helpMessage || this.props.helpMarkdown) && this.showHelp
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
toggleHelp() {
|
|
521
|
+
if (!this.props.helpMessage && !this.props.helpMarkdown) return
|
|
522
|
+
this.showHelp = !this.showHelp
|
|
523
|
+
return etch.update(this).then(() => {
|
|
524
|
+
// Use requestAnimationFrame to ensure DOM is fully rendered
|
|
525
|
+
requestAnimationFrame(() => {
|
|
526
|
+
this.updateHelpMarkdown()
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
hideHelp() {
|
|
532
|
+
if (this.showHelp) {
|
|
533
|
+
this.showHelp = false
|
|
534
|
+
return etch.update(this)
|
|
535
|
+
}
|
|
536
|
+
return Promise.resolve()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
getQuery() {
|
|
540
|
+
if (this.refs && this.refs.queryEditor) {
|
|
541
|
+
return this.refs.queryEditor.getText()
|
|
542
|
+
} else {
|
|
543
|
+
return ''
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
getFilterQuery() {
|
|
548
|
+
return this.props.filterQuery ? this.props.filterQuery(this.getQuery()) : this.getQuery()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
setQueryFromSelection() {
|
|
552
|
+
const editor = atom.workspace.getActiveTextEditor()
|
|
553
|
+
if (!editor) return false
|
|
554
|
+
const text = editor.getSelectedText()
|
|
555
|
+
if (!text || /\n/.test(text)) return false
|
|
556
|
+
this.refs.queryEditor.setText(text)
|
|
557
|
+
this.refs.queryEditor.selectAll()
|
|
558
|
+
return true
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
didChangeQuery() {
|
|
562
|
+
if (this.props.didChangeQuery) {
|
|
563
|
+
this.props.didChangeQuery(this.getFilterQuery())
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
this.hideHelp()
|
|
567
|
+
this.computeItems()
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
didClickItem(itemIndex) {
|
|
571
|
+
this.selectIndex(itemIndex)
|
|
572
|
+
this.confirmSelection()
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
computeItems(updateComponent) {
|
|
576
|
+
this.listItems = null
|
|
577
|
+
this.matchIndicesMap = new Map()
|
|
578
|
+
if (this.visibilityObserver) this.visibilityObserver.disconnect()
|
|
579
|
+
const filterFn = this.props.filter || this.fuzzyFilter.bind(this)
|
|
580
|
+
this.processedQuery = this.getFilterQuery()
|
|
581
|
+
this.items = filterFn(this.props.items.slice(), this.processedQuery)
|
|
582
|
+
if (this.props.order) {
|
|
583
|
+
this.items.sort(this.props.order)
|
|
584
|
+
}
|
|
585
|
+
if (this.props.maxResults) {
|
|
586
|
+
this.items = this.items.slice(0, this.props.maxResults)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
this.selectIndex(this.props.initialSelectionIndex, updateComponent)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
fuzzyFilter(items, query) {
|
|
593
|
+
if (query.length === 0) {
|
|
594
|
+
return items
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const replaceDiacritics = this.props.replaceDiacritics ?? true
|
|
598
|
+
if (replaceDiacritics) {
|
|
599
|
+
query = SelectListView.replaceDiacritics(query)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const threshold = this.props.filterThreshold ?? 0
|
|
603
|
+
const modifyScore = this.props.filterScoreModifier
|
|
604
|
+
const scoredItems = []
|
|
605
|
+
|
|
606
|
+
for (const item of items) {
|
|
607
|
+
let string = this.props.filterKeyForItem ? this.props.filterKeyForItem(item) : item
|
|
608
|
+
if (replaceDiacritics) {
|
|
609
|
+
string = SelectListView.replaceDiacritics(string)
|
|
610
|
+
}
|
|
611
|
+
const result = atom.ui.fuzzyMatcher.match(string, query, { recordMatchIndexes: true })
|
|
612
|
+
if (!result) continue
|
|
613
|
+
let score = result.score
|
|
614
|
+
if (modifyScore) {
|
|
615
|
+
score = modifyScore(score, item)
|
|
616
|
+
}
|
|
617
|
+
if (score > threshold) {
|
|
618
|
+
scoredItems.push({ item, score, matchIndexes: result.matchIndexes })
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
scoredItems.sort((a, b) => b.score - a.score)
|
|
623
|
+
for (const { item, matchIndexes } of scoredItems) {
|
|
624
|
+
this.matchIndicesMap.set(item, matchIndexes)
|
|
625
|
+
}
|
|
626
|
+
return scoredItems.map((i) => i.item)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
getMatchIndices(item) {
|
|
630
|
+
return this.matchIndicesMap ? this.matchIndicesMap.get(item) : null
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
getSelectedItem() {
|
|
634
|
+
if (this.selectionIndex === undefined) return null
|
|
635
|
+
return this.items[this.selectionIndex]
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
renderItemAtIndex(index) {
|
|
639
|
+
const item = this.items[index]
|
|
640
|
+
const selected = this.getSelectedItem() === item
|
|
641
|
+
const component = this.listItems[index].component
|
|
642
|
+
if (this.visibilityObserver) this.visibilityObserver.unobserve(component.element)
|
|
643
|
+
component.update({
|
|
644
|
+
element: this.props.elementForItem(item, { selected, index, visible: true }),
|
|
645
|
+
selected: selected,
|
|
646
|
+
onclick: () => this.didClickItem(index),
|
|
647
|
+
oncontextmenu: () => this.selectIndex(index)
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
selectPrevious() {
|
|
652
|
+
if (this.selectionIndex === undefined) return this.selectLast()
|
|
653
|
+
return this.selectIndex(this.selectionIndex - 1)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
selectNext() {
|
|
657
|
+
if (this.selectionIndex === undefined) return this.selectFirst()
|
|
658
|
+
return this.selectIndex(this.selectionIndex + 1)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
selectFirst() {
|
|
662
|
+
return this.selectIndex(0)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
selectLast() {
|
|
666
|
+
return this.selectIndex(this.items.length - 1)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
selectNone() {
|
|
670
|
+
return this.selectIndex(undefined)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
selectIndex(index, updateComponent = true) {
|
|
674
|
+
if (index >= this.items.length) {
|
|
675
|
+
index = 0
|
|
676
|
+
} else if (index < 0) {
|
|
677
|
+
index = this.items.length - 1
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const oldIndex = this.selectionIndex
|
|
681
|
+
|
|
682
|
+
this.selectionIndex = index
|
|
683
|
+
if (index !== undefined && this.props.didChangeSelection) {
|
|
684
|
+
this.props.didChangeSelection(this.getSelectedItem())
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (updateComponent) {
|
|
688
|
+
if (this.listItems) {
|
|
689
|
+
if (oldIndex >= 0) this.renderItemAtIndex(oldIndex)
|
|
690
|
+
if (index >= 0) this.renderItemAtIndex(index)
|
|
691
|
+
return etch.getScheduler().getNextUpdatePromise()
|
|
692
|
+
} else {
|
|
693
|
+
return etch.update(this)
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
return Promise.resolve()
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
selectItem(item) {
|
|
701
|
+
const index = this.items.indexOf(item)
|
|
702
|
+
if (index === -1) {
|
|
703
|
+
throw new Error('Cannot select the specified item because it does not exist.')
|
|
704
|
+
} else {
|
|
705
|
+
return this.selectIndex(index)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
confirmSelection() {
|
|
710
|
+
const selectedItem = this.getSelectedItem()
|
|
711
|
+
if (selectedItem != null) {
|
|
712
|
+
if (this.props.didConfirmSelection) {
|
|
713
|
+
this.props.didConfirmSelection(selectedItem)
|
|
714
|
+
}
|
|
715
|
+
} else {
|
|
716
|
+
if (this.props.didConfirmEmptySelection) {
|
|
717
|
+
this.props.didConfirmEmptySelection()
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
cancelSelection() {
|
|
723
|
+
if (this.props.didCancelSelection) {
|
|
724
|
+
this.props.didCancelSelection()
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
class ListItemView {
|
|
730
|
+
constructor(props) {
|
|
731
|
+
this.mouseDown = this.mouseDown.bind(this)
|
|
732
|
+
this.mouseUp = this.mouseUp.bind(this)
|
|
733
|
+
this.didClick = this.didClick.bind(this)
|
|
734
|
+
this.didContextMenu = this.didContextMenu.bind(this)
|
|
735
|
+
this.selected = props.selected
|
|
736
|
+
this.onclick = props.onclick
|
|
737
|
+
this.oncontextmenu = props.oncontextmenu
|
|
738
|
+
this.element = props.element
|
|
739
|
+
this.element.addEventListener('mousedown', this.mouseDown)
|
|
740
|
+
this.element.addEventListener('mouseup', this.mouseUp)
|
|
741
|
+
this.element.addEventListener('click', this.didClick)
|
|
742
|
+
this.element.addEventListener('contextmenu', this.didContextMenu)
|
|
743
|
+
if (this.selected) {
|
|
744
|
+
this.element.classList.add('selected')
|
|
745
|
+
}
|
|
746
|
+
this.domEventsDisposable = new Disposable(() => {
|
|
747
|
+
this.element.removeEventListener('mousedown', this.mouseDown)
|
|
748
|
+
this.element.removeEventListener('mouseup', this.mouseUp)
|
|
749
|
+
this.element.removeEventListener('click', this.didClick)
|
|
750
|
+
this.element.removeEventListener('contextmenu', this.didContextMenu)
|
|
751
|
+
})
|
|
752
|
+
etch.getScheduler().updateDocument(this.scrollIntoViewIfNeeded.bind(this))
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
mouseDown(event) {
|
|
756
|
+
event.preventDefault()
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
mouseUp(event) {
|
|
760
|
+
event.preventDefault()
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
didClick(event) {
|
|
764
|
+
event.preventDefault()
|
|
765
|
+
this.onclick()
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
didContextMenu() {
|
|
769
|
+
this.oncontextmenu()
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
destroy() {
|
|
773
|
+
this.element.remove()
|
|
774
|
+
this.domEventsDisposable.dispose()
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
update(props) {
|
|
778
|
+
this.element.removeEventListener('mousedown', this.mouseDown)
|
|
779
|
+
this.element.removeEventListener('mouseup', this.mouseUp)
|
|
780
|
+
this.element.removeEventListener('click', this.didClick)
|
|
781
|
+
this.element.removeEventListener('contextmenu', this.didContextMenu)
|
|
782
|
+
|
|
783
|
+
this.element.parentNode.replaceChild(props.element, this.element)
|
|
784
|
+
this.element = props.element
|
|
785
|
+
this.element.addEventListener('mousedown', this.mouseDown)
|
|
786
|
+
this.element.addEventListener('mouseup', this.mouseUp)
|
|
787
|
+
this.element.addEventListener('click', this.didClick)
|
|
788
|
+
this.element.addEventListener('contextmenu', this.didContextMenu)
|
|
789
|
+
if (props.selected) {
|
|
790
|
+
this.element.classList.add('selected')
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
this.selected = props.selected
|
|
794
|
+
this.onclick = props.onclick
|
|
795
|
+
this.oncontextmenu = props.oncontextmenu
|
|
796
|
+
etch.getScheduler().updateDocument(this.scrollIntoViewIfNeeded.bind(this))
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
scrollIntoViewIfNeeded() {
|
|
800
|
+
if (this.selected) {
|
|
801
|
+
this.element.scrollIntoViewIfNeeded(false)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
module.exports = SelectListView
|
package/select.less
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
.select-list-query-row {
|
|
2
|
+
position: relative;
|
|
3
|
+
.select-list-help-toggle {
|
|
4
|
+
position: absolute;
|
|
5
|
+
right: 8px;
|
|
6
|
+
top: 50%;
|
|
7
|
+
transform: translateY(-50%);
|
|
8
|
+
cursor: pointer;
|
|
9
|
+
opacity: 0.5;
|
|
10
|
+
z-index: 1;
|
|
11
|
+
&:hover {
|
|
12
|
+
opacity: 1;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.select-list .list-group {
|
|
18
|
+
overflow-x: hidden;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.select-list .empty-message {
|
|
22
|
+
color: @text-color-info;
|
|
23
|
+
margin-top: 10px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.select-list .error-message {
|
|
27
|
+
color: @text-color-error;
|
|
28
|
+
margin-top: 10px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.select-list .loading {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.select-list .keystroke {
|
|
37
|
+
border: solid 1px;
|
|
38
|
+
padding: .2em .4em;
|
|
39
|
+
border-radius: 4px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.select-list .help-message {
|
|
43
|
+
p, ul, ol {
|
|
44
|
+
margin: 0;
|
|
45
|
+
}
|
|
46
|
+
ul, ol {
|
|
47
|
+
padding-left: 1.5em;
|
|
48
|
+
}
|
|
49
|
+
.help-note {
|
|
50
|
+
color: @text-color-subtle;
|
|
51
|
+
font-size: 0.9em;
|
|
52
|
+
}
|
|
53
|
+
}
|