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 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, '&lt;').replace(/>/g, '&gt;')
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
+ }