@qtoggle/qui 1.19.11 → 1.20.0-alpha.2

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.
@@ -31,7 +31,7 @@ jobs:
31
31
  - name: Setup NodeJS
32
32
  uses: actions/setup-node@v4
33
33
  with:
34
- node-version: '18'
34
+ node-version: '24'
35
35
  - name: Install
36
36
  run: |
37
37
  npm install && rm -rf docs/* js/lib/* && npx jsdoc -c jsdoc.conf.json
@@ -63,11 +63,22 @@ jobs:
63
63
  - name: Setup NodeJS
64
64
  uses: actions/setup-node@v4
65
65
  with:
66
- node-version: '20'
66
+ node-version: '24'
67
67
  - name: Publish to NPM
68
68
  run: |
69
- npm install -g npm@latest &&
70
- npm publish
69
+ npm install -g npm@12
70
+ version=$(node -p "require('./package.json').version")
71
+ if [[ "${version}" == *-* ]]; then
72
+ # A prerelease must not take over the "latest" dist-tag, and npm insists on an explicit
73
+ # --tag for one. Name the tag after the prerelease identifier: 1.20.0-alpha.1 -> alpha.
74
+ npm_tag="${version#*-}"
75
+ npm_tag="${npm_tag%%.*}"
76
+ echo "Publishing ${version} under dist-tag ${npm_tag}"
77
+ npm publish --tag "${npm_tag}"
78
+ else
79
+ echo "Publishing ${version} under dist-tag latest"
80
+ npm publish
81
+ fi
71
82
  - name: Fetch python dist folder
72
83
  uses: actions/download-artifact@v4
73
84
  with:
@@ -80,7 +80,9 @@ class ComboField extends JQueryUIField {
80
80
  /**
81
81
  * Tell if a choice matches a search text or not.
82
82
  * @param {Object} choice
83
- * @param {String} searchText
83
+ * @param {String|RegExp} searchText the search text, compiled with
84
+ * {@link qui.utils.string.intelliSearchRegExp} and suitable for passing straight to
85
+ * {@link qui.utils.string.intelliSearch}
84
86
  * @returns {Boolean}
85
87
  */
86
88
  filterFunc(choice, searchText) {
package/js/index.js CHANGED
@@ -6,9 +6,8 @@ import * as RequireJSCompat from '$qui/base/require-js-compat.js'
6
6
  import $ from '$qui/lib/jquery.module.js'
7
7
  import Logger from '$qui/lib/logger.module.js'
8
8
 
9
- import '$qui/lib/jquery-ui.js'
9
+ import '$qui/lib/jquery-ui-widget.js'
10
10
  import '$qui/lib/jquery.mousewheel.js'
11
- import '$qui/lib/jquery.longpress.js'
12
11
  import '$qui/lib/pep.js'
13
12
 
14
13
  import {globalize} from '$qui/base/base.js'
@@ -38,26 +38,21 @@ class IconLabelListItem extends mix(ListItem).with(IconLabelViewMixin) {
38
38
  this.setClickable(selectMode !== Lists.LIST_SELECT_MODE_DISABLED)
39
39
  }
40
40
 
41
+ /**
42
+ * Return the text a search filter is matched against: the label and the sub-label joined in the order in which
43
+ * they are displayed, so that a filter can span both.
44
+ * @returns {String}
45
+ */
41
46
  getMatchPhrase() {
42
47
  if (this._matchPhrase == null) {
43
- this._matchPhrase = []
44
-
45
- /* Consider the entire label as is */
46
- if (this.getLabel()) {
47
- this._matchPhrase.push(this.getLabel().toLowerCase())
48
- }
49
- if (this.getSubLabel()) {
50
- this._matchPhrase.push(this.getSubLabel().toLowerCase())
51
- }
52
-
53
- this._matchPhrase = this._matchPhrase.filter(p => Boolean(p))
48
+ this._matchPhrase = [this.getLabel(), this.getSubLabel()].filter(Boolean).join(' ')
54
49
  }
55
50
 
56
51
  return this._matchPhrase
57
52
  }
58
53
 
59
54
  isMatch(filter) {
60
- return this.getMatchPhrase().some(p => StringUtils.intelliSearch(p, filter) != null)
55
+ return StringUtils.intelliSearch(this.getMatchPhrase(), filter) != null
61
56
  }
62
57
 
63
58
 
@@ -93,10 +93,24 @@ class ListItem extends mix().with(ViewMixin) {
93
93
  /* Visibility */
94
94
 
95
95
  /**
96
- * Tell if the item is hidden.
96
+ * Tell if the item is hidden, either because it has been explicitly hidden or because it is currently filtered
97
+ * out by its list's search filter.
97
98
  * @returns {Boolean}
98
99
  */
99
100
  isHidden() {
101
+ if (this._list != null && this._list.isItemFilteredOut(this)) {
102
+ return true
103
+ }
104
+
105
+ return this.isExplicitlyHidden()
106
+ }
107
+
108
+ /**
109
+ * Tell if the item has been hidden with {@link qui.lists.ListItem#hide}, as opposed to being filtered out by its
110
+ * list's search filter.
111
+ * @returns {Boolean}
112
+ */
113
+ isExplicitlyHidden() {
100
114
  return !this._visibilityManager.isElementVisible()
101
115
  }
102
116
 
@@ -117,12 +131,12 @@ class ListItem extends mix().with(ViewMixin) {
117
131
  /**
118
132
  * Tell if item matches a search filter. By default, uses {@link qui.utils.string.intelliSearch} on textual content
119
133
  * of the HTML element.
120
- * @param {String} filter search filter
134
+ * @param {String|RegExp} filter search filter, possibly precompiled with
135
+ * {@link qui.utils.string.intelliSearchRegExp}
121
136
  * @returns {Boolean}
122
137
  */
123
138
  isMatch(filter) {
124
- let text = this.getHTML().text().trim().toLowerCase()
125
- return StringUtils.intelliSearch(text, filter) != null
139
+ return StringUtils.intelliSearch(this.getHTML().text().trim(), filter) != null
126
140
  }
127
141
 
128
142
  /**
package/js/lists/list.js CHANGED
@@ -6,13 +6,23 @@ import {gettext} from '$qui/base/i18n.js'
6
6
  import {mix} from '$qui/base/mixwith.js'
7
7
  import StockIcon from '$qui/icons/stock-icon.js'
8
8
  import * as Lists from '$qui/lists/lists.js'
9
+ import * as Theme from '$qui/theme.js'
10
+ import Debouncer from '$qui/utils/debouncer.js'
11
+ import * as Gestures from '$qui/utils/gestures.js'
9
12
  import {asap} from '$qui/utils/misc.js'
10
- import * as ObjectUtils from '$qui/utils/object.js'
13
+ import * as StringUtils from '$qui/utils/string.js'
11
14
  import {ProgressViewMixin} from '$qui/views/common-views/common-views.js'
12
15
  import {StructuredViewMixin} from '$qui/views/common-views/common-views.js'
13
16
  import ViewMixin from '$qui/views/view.js'
14
17
 
15
18
 
19
+ /* Associates item elements with their items. A WeakMap is used rather than jQuery's element data, which would
20
+ * store the item in an expando on the DOM element itself, creating an item -> element -> item reference cycle. */
21
+ const itemsByElement = new WeakMap()
22
+
23
+ /* How long to wait after the last keystroke before filtering the list, in milliseconds */
24
+ const SEARCH_FILTER_DELAY = 100
25
+
16
26
  const logger = Logger.get('qui.lists.list')
17
27
 
18
28
 
@@ -58,6 +68,13 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
58
68
  this._addElem = null
59
69
  this._searchElem = null
60
70
  this._filterInput = null
71
+
72
+ /* Search filtering state */
73
+ this._filteredOutItems = new Set()
74
+ this._pendingReveal = new Set()
75
+ this._filterCollapseTimeout = null
76
+ this._revealFrameHandle = null
77
+ this._applySearchFilterDebouncer = new Debouncer(() => this._applySearchFilter(), SEARCH_FILTER_DELAY)
61
78
  }
62
79
 
63
80
  makeHTML() {
@@ -82,6 +99,28 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
82
99
  makeBody() {
83
100
  let bodyDiv = $('<div></div>', {class: 'qui-list-body'})
84
101
 
102
+ /* Item events are delegated to the list body rather than bound to each item. A list of a few hundred items
103
+ * would otherwise install a few thousand event handlers, including a mousemove handler per item. */
104
+
105
+ bodyDiv.on('click', 'div.qui-list-item', function (e) {
106
+ let item = this._itemFromElement($(e.currentTarget))
107
+ if (item) {
108
+ this._handleItemClick(item)
109
+ }
110
+ }.bind(this))
111
+
112
+ if (this._longPressMultipleSelection) {
113
+ Gestures.enableLongPress(bodyDiv, {
114
+ selector: 'div.qui-list-item',
115
+ onLongPress: function (element) {
116
+ let item = this._itemFromElement(element)
117
+ if (item) {
118
+ this._handleLongPress(item)
119
+ }
120
+ }.bind(this)
121
+ })
122
+ }
123
+
85
124
  if (this._searchEnabled) {
86
125
  this._enableSearch(bodyDiv)
87
126
  }
@@ -109,7 +148,10 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
109
148
  * @param {qui.lists.ListItem[]} items list items
110
149
  */
111
150
  setItems(items) {
112
- this._items.forEach(i => i.getHTML().remove())
151
+ this._items.forEach(function (i) {
152
+ this._forgetItemFilter(i)
153
+ i.getHTML().remove()
154
+ }, this)
113
155
 
114
156
  items.forEach(i => this.prepareItem(i))
115
157
  this._items = items
@@ -140,7 +182,10 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
140
182
  this._applySearchFilter(item)
141
183
  }
142
184
 
143
- this._items[index].getHTML().replaceWith(item.getHTML())
185
+ let oldItem = this._items[index]
186
+ this._forgetItemFilter(oldItem)
187
+
188
+ oldItem.getHTML().replaceWith(item.getHTML())
144
189
  this._items[index] = item
145
190
 
146
191
  }
@@ -180,8 +225,10 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
180
225
  * @returns {?qui.lists.ListItem} the removed item
181
226
  */
182
227
  removeItemAt(index) {
183
- if (this._items[index]) {
184
- this._items[index].getHTML().remove()
228
+ let item = this._items[index]
229
+ if (item) {
230
+ this._forgetItemFilter(item)
231
+ item.getHTML().remove()
185
232
  }
186
233
 
187
234
  return this._items.splice(index, 1)[0] || null
@@ -220,14 +267,26 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
220
267
  prepareItem(item) {
221
268
  item.setList(this)
222
269
 
223
- let html = item.getHTML()
224
-
225
- html.on('click', this._handleItemClick.bind(this, item))
226
- html.longpress(this._handleLongPress.bind(this))
270
+ /* Associate the item with its element, so that delegated event handlers can find it back */
271
+ itemsByElement.set(item.getHTML()[0], item)
227
272
 
228
273
  item.setSelectMode(this._selectMode)
229
274
  }
230
275
 
276
+ /**
277
+ * Return the item that owns a given element, if it belongs to this list.
278
+ * @param {jQuery} element
279
+ * @returns {?qui.lists.ListItem}
280
+ */
281
+ _itemFromElement(element) {
282
+ let item = itemsByElement.get(element[0])
283
+ if (!item || item.getList() !== this) {
284
+ return null
285
+ }
286
+
287
+ return item
288
+ }
289
+
231
290
  _handleItemClick(item) {
232
291
  /* Flag to prevent handling clicks on long press */
233
292
  if (item._wasLongPressed) {
@@ -261,7 +320,9 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
261
320
  addedItems.push(item)
262
321
  }
263
322
 
264
- if (ObjectUtils.deepEquals(oldItems, newItems)) {
323
+ /* Items are compared by identity: deep comparison would walk each item's entire object graph, including its
324
+ * HTML element and everything reachable from it */
325
+ if (oldItems.length === newItems.length && oldItems.every((item, i) => item === newItems[i])) {
265
326
  return /* Selection unchanged */
266
327
  }
267
328
 
@@ -290,7 +351,6 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
290
351
  return
291
352
  }
292
353
 
293
- // TODO: replace jQuery longpress plugin with a simple, more integrated long press event manager
294
354
  if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
295
355
  this.setSelectMode(Lists.LIST_SELECT_MODE_MULTIPLE)
296
356
  let selectedItems = this.getSelectedItems()
@@ -428,11 +488,11 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
428
488
  })
429
489
 
430
490
  searchInput.on('keyup', function () {
431
- list._applySearchFilter()
491
+ list._applySearchFilterDebouncer.call()
432
492
  })
433
493
 
434
494
  searchInput.on('paste', function () {
435
- list._applySearchFilter()
495
+ list._applySearchFilterDebouncer.call()
436
496
  })
437
497
 
438
498
  searchIcon.on('pointerdown', function () {
@@ -451,54 +511,120 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
451
511
  return searchElem
452
512
  }
453
513
 
514
+ /**
515
+ * Tell if an item is currently filtered out by the search filter.
516
+ * @param {qui.lists.ListItem} item
517
+ * @returns {Boolean}
518
+ */
519
+ isItemFilteredOut(item) {
520
+ return this._filteredOutItems.has(item)
521
+ }
522
+
523
+ _forgetItemFilter(item) {
524
+ this._pendingReveal.delete(item)
525
+
526
+ if (!this._filteredOutItems.delete(item)) {
527
+ return /* The filter never touched this item's element */
528
+ }
529
+
530
+ /* Undo what the filter did, so that an item that is added to a list again does not stay invisible */
531
+ item.getHTML().css({opacity: '', display: ''})
532
+ }
533
+
534
+ _makeSearchExpression() {
535
+ if (!this._filterInput) {
536
+ return null
537
+ }
538
+
539
+ let searchText = this._filterInput.val().trim()
540
+ if (!searchText) {
541
+ return null
542
+ }
543
+
544
+ /* The whole search text is compiled into a single expression, which also takes care of splitting it into
545
+ * groups. Compiling it here means compiling it once per list rather than once per item. */
546
+ return StringUtils.intelliSearchRegExp(searchText)
547
+ }
548
+
454
549
  _applySearchFilter(item = null) {
455
- let searchText = this._filterInput.val().trim().toLowerCase()
550
+ let searchExpression = this._makeSearchExpression()
551
+ let items = item ? [item] : this._items
552
+
553
+ /* Filtering is applied to the whole list at once: items are faded together, collapsed together by a single
554
+ * timer, and revealed together on a single frame. Going through each item's visibility manager instead would
555
+ * schedule two timeouts per item whose visibility changes.
556
+ *
557
+ * Visibility is driven by inline styles rather than by classes, so that filtering does not depend on a
558
+ * stylesheet built from the same sources as this file. The fade comes from the opacity transition that
559
+ * div.qui-list-child already carries. show() and hide() drive the same inline properties through the item's
560
+ * visibility manager, so revealing an item leaves it hidden if it has also been explicitly hidden. */
561
+
562
+ let toReveal = []
563
+ let toCollapse = []
564
+
565
+ items.forEach(function (item) {
566
+
567
+ let filteredOut = searchExpression != null && !item.isMatch(searchExpression)
568
+ if (filteredOut === this._filteredOutItems.has(item)) {
569
+ return /* Nothing to do for this item */
570
+ }
456
571
 
457
- searchText = searchText.replace(/\s\s+/g, ' ')
458
- let searchTextParts = searchText.split(' ')
572
+ let html = item.getHTML()
459
573
 
460
- /* If item is specified, apply filtering only to given item */
461
- if (item) {
462
- if (!this._filterInput) {
463
- if (item.isHidden()) {
464
- item.show()
574
+ if (filteredOut) {
575
+ this._filteredOutItems.add(item)
576
+ this._pendingReveal.delete(item)
577
+
578
+ if (html[0].isConnected) {
579
+ html.css('opacity', '0') /* Starts fading out */
580
+ toCollapse.push(item)
581
+ }
582
+ else { /* Not part of the document yet, so there is nothing to transition from */
583
+ html.css({opacity: '0', display: 'none'})
465
584
  }
466
585
  }
467
586
  else {
468
- let match = searchTextParts.every(s => item.isMatch(s))
469
- if (match) {
470
- if (item.isHidden()) {
471
- item.show()
472
- }
473
- }
474
- else {
475
- if (!item.isHidden()) {
476
- item.hide()
477
- }
478
- }
587
+ this._filteredOutItems.delete(item)
588
+
589
+ /* Take up layout again, still transparent, and start fading in on the next frame, unless the item
590
+ * has been explicitly hidden, in which case its visibility manager owns the display property */
591
+ html.css('display', item.isExplicitlyHidden() ? 'none' : '')
592
+ this._pendingReveal.add(item)
593
+ toReveal.push(item)
479
594
  }
480
595
 
481
- return
596
+ }, this)
597
+
598
+ if (toCollapse.length) {
599
+ this._scheduleFilterCollapse()
482
600
  }
483
601
 
484
- if (!this._filterInput) {
485
- this._items.filter(i => i.isHidden()).forEach(i => i.show())
486
- return
602
+ if (toReveal.length && this._revealFrameHandle == null) {
603
+ this._revealFrameHandle = window.requestAnimationFrame(function () {
604
+
605
+ this._revealFrameHandle = null
606
+ this._pendingReveal.forEach(function (item) {
607
+ if (!this._filteredOutItems.has(item)) {
608
+ item.getHTML().css('opacity', '')
609
+ }
610
+ }, this)
611
+ this._pendingReveal.clear()
612
+
613
+ }.bind(this))
487
614
  }
615
+ }
488
616
 
489
- this._items.forEach(function (item) {
490
- let match = searchTextParts.every(s => item.isMatch(s))
491
- if (match) {
492
- if (item.isHidden()) {
493
- item.show()
494
- }
495
- }
496
- else {
497
- if (!item.isHidden()) {
498
- item.hide()
499
- }
500
- }
501
- })
617
+ _scheduleFilterCollapse() {
618
+ if (this._filterCollapseTimeout != null) {
619
+ return /* Items added to the batch in the meantime are collapsed by the pending timeout */
620
+ }
621
+
622
+ this._filterCollapseTimeout = setTimeout(function () {
623
+
624
+ this._filterCollapseTimeout = null
625
+ this._filteredOutItems.forEach(i => i.getHTML().css('display', 'none'))
626
+
627
+ }.bind(this), Theme.getTransitionDuration())
502
628
  }
503
629
 
504
630
  _clearSearch() {
package/js/pages/pages.js CHANGED
@@ -17,6 +17,12 @@ import * as Breadcrumbs from './breadcrumbs.js'
17
17
  let pagesContainer = null
18
18
  let currentContext = null
19
19
 
20
+ /* Scroll handling state. Scroll events are coalesced into one update per frame, and that update reads before it
21
+ * writes, so that a write never sits between two reads and forces a synchronous layout. */
22
+ let scrollUpdatePending = false
23
+ let scrolledPages = new Set()
24
+ let contentScrolled = null
25
+
20
26
 
21
27
  /* Page context */
22
28
 
@@ -151,9 +157,8 @@ function updatePagesVisibility() {
151
157
  GlobalGlass.updateVisibility()
152
158
  }
153
159
 
154
- function updateContentScroll() {
155
- /* Scrolled content */
156
- let scrolled = currentContext.getPages().some(function (p) {
160
+ function isContentScrolled() {
161
+ return currentContext.getPages().some(function (p) {
157
162
  if (!p.isVisible()) {
158
163
  return false
159
164
  }
@@ -161,19 +166,61 @@ function updateContentScroll() {
161
166
  return p.getPageHTML()[0].scrollTop !== 0
162
167
 
163
168
  })
169
+ }
170
+
171
+ function applyContentScrolled(scrolled) {
172
+ if (scrolled === contentScrolled) {
173
+ return
174
+ }
175
+
176
+ contentScrolled = scrolled
164
177
  Window.$body.toggleClass('content-scrolled', scrolled)
165
178
  }
166
179
 
167
- function handlePageScroll() {
168
- updateContentScroll()
180
+ function updateContentScroll() {
181
+ applyContentScrolled(isContentScrolled())
182
+ }
183
+
184
+ function runScrollUpdate() {
185
+ scrollUpdatePending = false
186
+
187
+ let pages = Array.from(scrolledPages)
188
+ scrolledPages.clear()
189
+
190
+ /* Read phase: nothing has been written yet in this frame, so these reads are served from a clean layout */
191
+ let scrolled = isContentScrolled()
169
192
 
193
+ /* Page handlers read their own scroll state before writing to their own elements. They run before the body class
194
+ * is updated, so that no write precedes their reads. */
195
+ pages.forEach(function (page) {
196
+ /* The page may have been removed from its context, or its whole context may have been swapped out by
197
+ * setCurrentContext(), between the scroll event and this frame */
198
+ if (page.getContext() !== currentContext) {
199
+ return
200
+ }
201
+
202
+ page.handleVertScroll()
203
+ })
204
+
205
+ /* Write phase */
206
+ applyContentScrolled(scrolled)
207
+ }
208
+
209
+ function handlePageScroll() {
170
210
  let $this = $(this)
171
211
  let page = $this.data('page')
172
212
  if (!page) {
173
213
  throw AssertionError('page scroll event from a non-page HTML element')
174
214
  }
175
215
 
176
- page.handleVertScroll()
216
+ scrolledPages.add(page)
217
+
218
+ if (scrollUpdatePending) {
219
+ return
220
+ }
221
+
222
+ scrollUpdatePending = true
223
+ window.requestAnimationFrame(runScrollUpdate)
177
224
  }
178
225
 
179
226
  function triggerPageResize() {
@@ -202,6 +249,7 @@ function attachPageHTMLHandlers(page) {
202
249
  }
203
250
 
204
251
  function detachPageHTMLHandlers(page) {
252
+ scrolledPages.delete(page)
205
253
  page.getPageHTML().off('scroll', handlePageScroll)
206
254
  page.getPageHTML().off('transitionend', triggerPageResizeOnTransitionEnd)
207
255
  }
@@ -2,9 +2,14 @@
2
2
  * @namespace qui.utils.gestures
3
3
  */
4
4
 
5
+ import $ from '$qui/lib/jquery.module.js'
6
+
5
7
  import * as Window from '$qui/window.js'
6
8
 
7
9
 
10
+ const LONG_PRESS_DATA_KEY = 'qui.utils.gestures.longPress'
11
+
12
+
8
13
  /**
9
14
  * Drag Move Callback Function.
10
15
  * @callback qui.utils.gestures.DragMoveCallback
@@ -163,3 +168,135 @@ export function disableDragging(element) {
163
168
  element.attr('touch-action', '') /* Required for pep.js (on iOS) */
164
169
  element.off('pointerdown', draggingData.pointerDown)
165
170
  }
171
+
172
+ /**
173
+ * Long Press Callback Function.
174
+ * @callback qui.utils.gestures.LongPressCallback
175
+ * @param {jQuery} element the pressed element
176
+ * @param {jQuery.Event} event the pointer event that started the press
177
+ */
178
+
179
+ /**
180
+ * Setup long press detection on an HTML element.
181
+ *
182
+ * Detection is based on pointer events, which never block scrolling; a press is automatically abandoned as soon as
183
+ * the browser starts scrolling with the same pointer, or as soon as the pointer moves further than `moveThreshold`.
184
+ *
185
+ * When a `selector` is given, presses are detected for matching descendants of `element` rather than for `element`
186
+ * itself. This allows handling long presses on a large number of children with a single set of event handlers.
187
+ *
188
+ * @alias qui.utils.gestures.enableLongPress
189
+ * @param {jQuery} element the element on which presses are detected
190
+ * @param {qui.utils.gestures.LongPressCallback} onLongPress called when the element has been pressed for at least
191
+ * `duration` milliseconds
192
+ * @param {?qui.utils.gestures.LongPressCallback} [onShortPress] called when the element has been released before
193
+ * `duration` milliseconds have elapsed
194
+ * @param {?String} [selector] an optional selector restricting detection to matching descendants of `element`
195
+ * @param {Number} [duration] how long the element must be pressed for a long press, in milliseconds (defaults to
196
+ * `500`)
197
+ * @param {Number} [moveThreshold] how far the pointer may move before the press is abandoned, in pixels (defaults to
198
+ * `10`)
199
+ */
200
+ export function enableLongPress(
201
+ element,
202
+ {onLongPress, onShortPress = null, selector = null, duration = 500, moveThreshold = 10}
203
+ ) {
204
+ let timeoutHandle = null
205
+ let pressedElement = null
206
+ let pressedPointerId = null
207
+ let startPageX = 0
208
+ let startPageY = 0
209
+
210
+ function abandon() {
211
+ if (timeoutHandle != null) {
212
+ clearTimeout(timeoutHandle)
213
+ timeoutHandle = null
214
+ }
215
+
216
+ pressedElement = null
217
+ pressedPointerId = null
218
+
219
+ Window.$body.off('pointermove', pointerMove)
220
+ .off('pointerup pointercancel', pointerUp)
221
+ }
222
+
223
+ function isPrimaryPointer(e) {
224
+ /* jQuery normalizes pointerId and pointerType, but not isPrimary, which must be read from the original
225
+ * event; events triggered programmatically have no original event and are considered primary */
226
+ return !e.originalEvent || e.originalEvent.isPrimary !== false
227
+ }
228
+
229
+ function pointerDown(e) {
230
+ /* Only the primary pointer (first finger, left mouse button) can start a press */
231
+ if (!isPrimaryPointer(e) || (e.pointerType === 'mouse' && e.button !== 0)) {
232
+ return
233
+ }
234
+
235
+ abandon() /* Any previous press is implicitly abandoned */
236
+
237
+ pressedElement = $(this)
238
+ pressedPointerId = e.pointerId
239
+ startPageX = e.pageX
240
+ startPageY = e.pageY
241
+
242
+ timeoutHandle = setTimeout(function () {
243
+
244
+ timeoutHandle = null
245
+
246
+ let element = pressedElement
247
+ abandon()
248
+ onLongPress(element, e)
249
+
250
+ }, duration)
251
+
252
+ Window.$body.on('pointermove', pointerMove)
253
+ .on('pointerup pointercancel', pointerUp)
254
+ }
255
+
256
+ function pointerMove(e) {
257
+ /* Ignore any pointer other than the one that started the press */
258
+ if (!pressedElement || e.pointerId !== pressedPointerId) {
259
+ return
260
+ }
261
+
262
+ if (Math.abs(e.pageX - startPageX) > moveThreshold || Math.abs(e.pageY - startPageY) > moveThreshold) {
263
+ abandon()
264
+ }
265
+ }
266
+
267
+ function pointerUp(e) {
268
+ /* Ignore any pointer other than the one that started the press */
269
+ if (!pressedElement || e.pointerId !== pressedPointerId) {
270
+ return
271
+ }
272
+
273
+ /* A pending timeout means the press has been released before becoming a long press */
274
+ let shortPress = timeoutHandle != null
275
+
276
+ let element = pressedElement
277
+ abandon()
278
+
279
+ if (shortPress && onShortPress) {
280
+ onShortPress(element, e)
281
+ }
282
+ }
283
+
284
+ element.data(LONG_PRESS_DATA_KEY, {pointerDown: pointerDown, abandon: abandon, selector: selector})
285
+ element.on('pointerdown', selector, pointerDown)
286
+ }
287
+
288
+ /**
289
+ * Disable previously configured long press support on an HTML element.
290
+ * @alias qui.utils.gestures.disableLongPress
291
+ * @param {jQuery} element the element on which presses are detected
292
+ */
293
+ export function disableLongPress(element) {
294
+ let longPressData = element.data(LONG_PRESS_DATA_KEY)
295
+ if (!longPressData) {
296
+ return
297
+ }
298
+
299
+ longPressData.abandon()
300
+ element.off('pointerdown', longPressData.selector, longPressData.pointerDown)
301
+ element.removeData(LONG_PRESS_DATA_KEY)
302
+ }
@@ -174,19 +174,46 @@ export function fromUTF8(s) {
174
174
  }
175
175
 
176
176
  /**
177
- * Intelligently search for an input sequence in a string. All characters in the input sequence must be present in the
178
- * searched string, in the respective order.
177
+ * Intelligently search for an input sequence in a string.
178
+ *
179
+ * The input sequence is made of groups of characters separated by whitespace. Each group must be present in the
180
+ * searched string as it was given, without anything in between, and the groups must appear in the order in which they
181
+ * were given, each one starting after the previous one ends. Searching is case insensitive.
182
+ *
183
+ * For example, `"temp sens"` matches `"temperature sensor"`, while neither `"tmp"` nor `"sens temp"` does.
184
+ *
179
185
  * @alias qui.utils.string.intelliSearch
180
186
  * @param {String} s string to search into
181
- * @param {String} search string to search for
187
+ * @param {String|RegExp} search string to search for, or an expression previously compiled with
188
+ * {@link qui.utils.string.intelliSearchRegExp}
182
189
  * @returns {?RegExpMatchArray}
183
190
  */
184
191
  export function intelliSearch(s, search) {
185
- let rexStr = Array.prototype.map.call(search, function (c) {
186
- return `${REGEX_ESCAPE_CHARS.includes(c) ? '\\' : ''}${c}.*`
187
- }).join('')
188
-
189
- let rex = new RegExp(rexStr, 'i')
192
+ let rex = (search instanceof RegExp) ? search : intelliSearchRegExp(search)
190
193
 
191
194
  return s.match(rex)
192
195
  }
196
+
197
+ /**
198
+ * Compile the regular expression used by {@link qui.utils.string.intelliSearch}.
199
+ *
200
+ * Compiling once and passing the result to {@link qui.utils.string.intelliSearch} avoids recompiling the same
201
+ * expression when matching a single search string against many candidates.
202
+ *
203
+ * @alias qui.utils.string.intelliSearchRegExp
204
+ * @param {String} search the search string
205
+ * @returns {RegExp}
206
+ */
207
+ export function intelliSearchRegExp(search) {
208
+ let groups = search.split(/\s+/).filter(group => group.length > 0)
209
+
210
+ /* Characters of a group must be adjacent in the searched string, while groups themselves may be arbitrarily far
211
+ * apart, as long as they follow each other */
212
+ let rexStr = groups.map(function (group) {
213
+ return Array.prototype.map.call(group, function (c) {
214
+ return `${REGEX_ESCAPE_CHARS.includes(c) ? '\\' : ''}${c}`
215
+ }).join('')
216
+ }).join('.*')
217
+
218
+ return new RegExp(rexStr, 'i')
219
+ }
@@ -1,5 +1,5 @@
1
1
 
2
- import '$qui/lib/jquery-ui.js'
2
+ import '$qui/lib/jquery-ui-widget.js'
3
3
  import $ from '$qui/lib/jquery.module.js'
4
4
 
5
5
 
@@ -555,9 +555,10 @@ $.widget('qui.combo', $.qui.basewidget, {
555
555
  })
556
556
  }
557
557
 
558
- let searchText = this._filterInput.val().trim().toLowerCase()
559
- searchText = searchText.replace(/\s\s+/g, ' ')
560
- let searchTextParts = searchText.split(' ')
558
+ /* The whole search text is compiled into a single expression, which also takes care of splitting it into
559
+ * groups. Compiling it here means compiling it once per combo rather than once per choice. */
560
+ let searchText = this._filterInput.val().trim()
561
+ let searchExpression = searchText ? StringUtils.intelliSearchRegExp(searchText) : null
561
562
  let children = this._itemContainer.children('div.qui-combo-item')
562
563
 
563
564
  let filterFunc = this.options.filterFunc
@@ -580,7 +581,7 @@ $.widget('qui.combo', $.qui.basewidget, {
580
581
  let visibleCount = 0
581
582
  this._getChoices().forEach(function (choice, i) {
582
583
 
583
- let visible = !searchText || searchTextParts.every(p => filterFunc(choice, p))
584
+ let visible = !searchExpression || filterFunc(choice, searchExpression)
584
585
  if (visibleCount > MAX_VISIBLE_ITEMS && this._maxHeightSet) {
585
586
  visible = false
586
587
  }
@@ -1,7 +1,8 @@
1
1
 
2
2
  import $ from '$qui/lib/jquery.module.js'
3
3
 
4
- import {gettext} from '$qui/base/i18n.js'
4
+ import {gettext} from '$qui/base/i18n.js'
5
+ import * as Gestures from '$qui/utils/gestures.js'
5
6
 
6
7
  import * as BaseWidget from '../base-widget.js' /* Needed */
7
8
 
@@ -146,25 +147,29 @@ $.widget('qui.updown', $.qui.basewidget, {
146
147
 
147
148
  /* Install up/down buttons long press handlers */
148
149
 
149
- this._downButton.longpress(function () {
150
- if (widget.options.readonly || widget.options.disabled) {
151
- return
152
- }
150
+ Gestures.enableLongPress(this._downButton, {
151
+ onLongPress: function () {
152
+ if (widget.options.readonly || widget.options.disabled) {
153
+ return
154
+ }
153
155
 
154
- incDecLoopActive = true
155
- decLoop()
156
+ incDecLoopActive = true
157
+ decLoop()
158
+ }
156
159
  })
157
160
  this._downButton.on('pointerup pointerleave pointerout pointercancel', function () {
158
161
  incDecLoopActive = false
159
162
  })
160
163
 
161
- this._upButton.longpress(function () {
162
- if (widget.options.readonly || widget.options.disabled) {
163
- return
164
- }
164
+ Gestures.enableLongPress(this._upButton, {
165
+ onLongPress: function () {
166
+ if (widget.options.readonly || widget.options.disabled) {
167
+ return
168
+ }
165
169
 
166
- incDecLoopActive = true
167
- incLoop()
170
+ incDecLoopActive = true
171
+ incLoop()
172
+ }
168
173
  })
169
174
  this._upButton.on('pointerup pointerleave pointerout pointercancel', function () {
170
175
  incDecLoopActive = false
package/less/lists.less CHANGED
@@ -29,10 +29,28 @@ div.qui-list-child {
29
29
 
30
30
  div.qui-list-child.qui-list-item {
31
31
 
32
+ /* Nothing inside an item is ever painted outside of it, since div.qui-list-child above already clips with
33
+ * overflow: hidden. Containment is therefore visually inert here, but it lets the engine scope layout and style
34
+ * invalidation to the single row that changed, instead of the whole list, and skip painting off-screen rows. */
35
+ contain: layout style paint;
36
+
32
37
  &.hidden {
33
38
  opacity: 0;
34
39
  }
35
40
 
41
+ /* Only an item whose whole content is an icon-label view has a height we can predict: min-height: 3em in
42
+ * icon-label-view.less, with the content kept to a single ellipsised line. Such a view can have its off-screen
43
+ * rendering skipped against a reliable size estimate. Everything else built on ListItem is deliberately left out,
44
+ * table rows above all -- their content is a set of cells and their height is unconstrained.
45
+ *
46
+ * The plain contain-intrinsic-size comes first for engines that predate the auto keyword and would otherwise drop
47
+ * the declaration, collapsing a skipped view to nothing. */
48
+ & > div.qui-icon-label-view {
49
+ content-visibility: auto;
50
+ contain-intrinsic-size: 0 3em;
51
+ contain-intrinsic-size: auto 3em;
52
+ }
53
+
36
54
  }
37
55
 
38
56
 
package/package.json CHANGED
@@ -1,28 +1,28 @@
1
1
  {
2
2
  "name": "@qtoggle/qui",
3
3
  "description": "A JavaScript UI library with batteries included.",
4
- "version": "1.19.11",
4
+ "version": "1.20.0-alpha.2",
5
5
  "author": {
6
6
  "name": "Calin Crisan",
7
7
  "email": "ccrisan@gmail.com"
8
8
  },
9
9
  "repository": {
10
- "url": "https://github.com/qtoggle/qui.git"
10
+ "url": "git+https://github.com/qtoggle/qui.git"
11
11
  },
12
12
  "keywords": [],
13
13
  "license": "Apache-2.0",
14
14
  "dependencies": {
15
15
  "jquery": "^3",
16
16
  "jquery-mousewheel": "*",
17
- "jquery-ui-dist": "*",
17
+ "jquery-ui": "^1",
18
18
  "js-logger": "*",
19
19
  "pepjs": "*"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@babel/core": "^7.27.0",
23
+ "@babel/eslint-parser": "^7.27.0",
23
24
  "@babel/plugin-proposal-class-properties": "^7.18.6",
24
25
  "@babel/preset-env": "^7.27.0",
25
- "@babel/eslint-parser": "^7.27.0",
26
26
  "babel-loader": "^8",
27
27
  "css-loader": "^3",
28
28
  "eslint": "^9",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qui-server"
3
- version = "1.19.11"
3
+ version = "1.20.0-alpha.2"
4
4
  description = "A fully fledged qToggle implementation written in Python"
5
5
  authors = [
6
6
  {name = "Calin Crisan", email = "ccrisan@gmail.com"},
@@ -29,6 +29,7 @@ package = true
29
29
  [tool.ruff]
30
30
  line-length = 120
31
31
  lint.extend-select = ["I", "RUF022"]
32
+ lint.extend-ignore = ["RUF012"]
32
33
  lint.isort.lines-after-imports = 2
33
34
  lint.isort.lines-between-types = 1
34
35
  lint.isort.force-wrap-aliases = true
@@ -5,6 +5,6 @@ echo "Creating symlinks to libs"
5
5
  cd js/lib
6
6
  ln -sf ../../../../jquery/dist/jquery.js .
7
7
  ln -sf ../../../../jquery-mousewheel/jquery.mousewheel.js .
8
- ln -sf ../../../../jquery-ui-dist/jquery-ui.js
8
+ ln -sf ../../../../jquery-ui/ui/widget.js jquery-ui-widget.js
9
9
  ln -sf ../../../../js-logger/src/logger.js
10
10
  ln -sf ../../../../pepjs/dist/pep.js
@@ -1,79 +0,0 @@
1
- /**
2
- * Longpress is a jQuery plugin that makes it easy to support long press
3
- * events on mobile devices and desktop borwsers.
4
- *
5
- * @name longpress
6
- * @version 0.1.2
7
- * @requires jQuery v1.2.3+
8
- * @author Vaidik Kapoor
9
- * @license MIT License - http://www.opensource.org/licenses/mit-license.php
10
- *
11
- * For usage and examples, check out the README at:
12
- * http://github.com/vaidik/jquery-longpress/
13
- *
14
- * Copyright (c) 2008-2013, Vaidik Kapoor (kapoor [*dot*] vaidik -[at]- gmail [*dot*] com)
15
- */
16
-
17
- (function($) {
18
- $.fn.longpress = function(longCallback, shortCallback, duration) {
19
- if (typeof duration === "undefined") {
20
- duration = 500;
21
- }
22
-
23
- return this.each(function() {
24
- var $this = $(this);
25
-
26
- // to keep track of how long something was pressed
27
- var mouse_down_time;
28
- var timeout;
29
-
30
- // mousedown or touchstart callback
31
- function mousedown_callback(e) {
32
- mouse_down_time = new Date().getTime();
33
- var context = $(this);
34
-
35
- // set a timeout to call the longpress callback when time elapses
36
- timeout = setTimeout(function() {
37
- if (typeof longCallback === "function") {
38
- longCallback.call(context, e);
39
- } else {
40
- $.error('Callback required for long press. You provided: ' + typeof longCallback);
41
- }
42
- }, duration);
43
- }
44
-
45
- // mouseup or touchend callback
46
- function mouseup_callback(e) {
47
- var press_time = new Date().getTime() - mouse_down_time;
48
- if (press_time < duration) {
49
- // cancel the timeout
50
- clearTimeout(timeout);
51
-
52
- // call the shortCallback if provided
53
- if (typeof shortCallback === "function") {
54
- shortCallback.call($(this), e);
55
- } else if (typeof shortCallback === "undefined") {
56
- ;
57
- } else {
58
- $.error('Optional callback for short press should be a function.');
59
- }
60
- }
61
- }
62
-
63
- // cancel long press event if the finger or mouse was moved
64
- function move_callback(e) {
65
- clearTimeout(timeout);
66
- }
67
-
68
- // Browser Support
69
- $this.on('mousedown', mousedown_callback);
70
- $this.on('mouseup', mouseup_callback);
71
- $this.on('mousemove', move_callback);
72
-
73
- // Mobile Support
74
- $this.on('touchstart', mousedown_callback);
75
- $this.on('touchend', mouseup_callback);
76
- $this.on('touchmove', move_callback);
77
- });
78
- };
79
- }(jQuery));