@qtoggle/qui 1.20.0-alpha.7 → 1.20.0-beta.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.
- package/js/forms/common-forms/page-form.js +16 -8
- package/js/icons/icon.js +19 -0
- package/js/lists/common-items/icon-label-list-item.js +15 -3
- package/js/lists/list.js +41 -7
- package/js/main-ui/status.js +18 -5
- package/js/utils/promise.js +15 -0
- package/js/widgets/widgets.js +41 -0
- package/less/base.less +1 -0
- package/less/icon-label-view.less +27 -6
- package/less/pages/page.less +12 -0
- package/less/theme-dark.less +3 -0
- package/less/theme-light.less +3 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
|
|
2
|
-
import {gettext}
|
|
3
|
-
import {mix}
|
|
4
|
-
import StockIcon
|
|
5
|
-
import PageMixin
|
|
6
|
-
import * as Sections
|
|
7
|
-
import * as ObjectUtils
|
|
8
|
-
import * as
|
|
2
|
+
import {gettext} from '$qui/base/i18n.js'
|
|
3
|
+
import {mix} from '$qui/base/mixwith.js'
|
|
4
|
+
import StockIcon from '$qui/icons/stock-icon.js'
|
|
5
|
+
import PageMixin from '$qui/pages/page.js'
|
|
6
|
+
import * as Sections from '$qui/sections/sections.js'
|
|
7
|
+
import * as ObjectUtils from '$qui/utils/object.js'
|
|
8
|
+
import * as PromiseUtils from '$qui/utils/promise.js'
|
|
9
|
+
import * as Window from '$qui/window.js'
|
|
9
10
|
|
|
10
11
|
import Form from '../form.js'
|
|
11
12
|
|
|
@@ -59,7 +60,14 @@ class PageForm extends mix(Form).with(PageMixin) {
|
|
|
59
60
|
|
|
60
61
|
handleBecomeCurrent() {
|
|
61
62
|
super.handleBecomeCurrent()
|
|
62
|
-
|
|
63
|
+
|
|
64
|
+
/* Measured after the next paint, when layout is already up to date. Measuring now would lay out a freshly built
|
|
65
|
+
* form on the spot, and again after the classes set here. */
|
|
66
|
+
PromiseUtils.afterNextPaint().then(function () {
|
|
67
|
+
if (this.isCurrent()) {
|
|
68
|
+
this._updateVertScroll()
|
|
69
|
+
}
|
|
70
|
+
}.bind(this))
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
handleVertScroll() {
|
package/js/icons/icon.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
|
|
2
|
+
import * as ObjectUtils from '$qui/utils/object.js'
|
|
3
|
+
|
|
4
|
+
|
|
2
5
|
const ICON_DATA_ATTR = '_icon'
|
|
3
6
|
|
|
4
7
|
|
|
@@ -49,6 +52,22 @@ class Icon {
|
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Tell if another icon would render the same as this one.
|
|
57
|
+
* @param {?qui.icons.Icon} other
|
|
58
|
+
* @returns {Boolean}
|
|
59
|
+
*/
|
|
60
|
+
equals(other) {
|
|
61
|
+
if (other === this) {
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
if (!other || other.constructor !== this.constructor) {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return ObjectUtils.deepEquals(this.toAttributes(), other.toAttributes())
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
/**
|
|
53
72
|
* Return the icon that has been previously applied to an element.
|
|
54
73
|
* @param {jQuery} element
|
|
@@ -45,9 +45,21 @@ class IconLabelListItem extends mix(ListItem).with(IconLabelViewMixin) {
|
|
|
45
45
|
return false
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
/* Updates arrive for every server event, mostly with nothing changed. Re-applying an icon rebuilds its elements
|
|
49
|
+
* with a fade and may sample its colors, so each part is only set when it differs. */
|
|
50
|
+
if (other.getLabel() !== this.getLabel()) {
|
|
51
|
+
this.setLabel(other.getLabel())
|
|
52
|
+
}
|
|
53
|
+
if (other.getSubLabel() !== this.getSubLabel()) {
|
|
54
|
+
this.setSubLabel(other.getSubLabel())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let icon = other.getIcon()
|
|
58
|
+
let currentIcon = this.getIcon()
|
|
59
|
+
let iconChanged = icon ? !icon.equals(currentIcon) : currentIcon != null
|
|
60
|
+
if (iconChanged) {
|
|
61
|
+
this.setIcon(icon)
|
|
62
|
+
}
|
|
51
63
|
|
|
52
64
|
return true
|
|
53
65
|
}
|
package/js/lists/list.js
CHANGED
|
@@ -75,6 +75,10 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
|
|
|
75
75
|
this._filterCollapseTimeout = null
|
|
76
76
|
this._revealFrameHandle = null
|
|
77
77
|
this._applySearchFilterDebouncer = new Debouncer(() => this._applySearchFilter(), SEARCH_FILTER_DELAY)
|
|
78
|
+
|
|
79
|
+
/* Counts clicks, so that only the last of several quick clicks gets handled */
|
|
80
|
+
this._selectionClickCount = 0
|
|
81
|
+
this._selectionChangeFromItems = null
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
makeHTML() {
|
|
@@ -370,24 +374,54 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
|
|
|
370
374
|
return /* Selection unchanged */
|
|
371
375
|
}
|
|
372
376
|
|
|
373
|
-
let
|
|
374
|
-
|
|
375
|
-
promise.then(function () {
|
|
377
|
+
let setSelection = function (deselected, selected) {
|
|
376
378
|
try {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
+
deselected.forEach(i => i.setSelected(false))
|
|
380
|
+
selected.forEach(i => i.setSelected(true))
|
|
379
381
|
}
|
|
380
382
|
catch (e) {
|
|
381
383
|
logger.errorStack('setSelected failed', e)
|
|
382
384
|
}
|
|
383
|
-
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* Show the new selection first and let its fade finish before onSelectionChange(). That usually builds and
|
|
388
|
+
* pushes a whole page, which blocks the main thread and would otherwise hold back the tap feedback. */
|
|
389
|
+
setSelection(removedItems, addedItems)
|
|
390
|
+
|
|
391
|
+
/* Quick clicks in a row make one change, from the selection before the first one to that of the last one, so
|
|
392
|
+
* that no page gets built for a selection that is already gone */
|
|
393
|
+
if (this._selectionChangeFromItems == null) {
|
|
394
|
+
this._selectionChangeFromItems = oldItems
|
|
395
|
+
}
|
|
396
|
+
let clickCount = ++this._selectionClickCount
|
|
397
|
+
let fromItems = null
|
|
398
|
+
|
|
399
|
+
new Promise(resolve => window.requestAnimationFrame(resolve)).then(function () {
|
|
400
|
+
return Theme.afterTransitionPromise()
|
|
401
|
+
}).then(function () {
|
|
402
|
+
if (clickCount !== this._selectionClickCount) {
|
|
403
|
+
return /* A later click took over */
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
fromItems = this._selectionChangeFromItems
|
|
407
|
+
this._selectionChangeFromItems = null
|
|
408
|
+
if (fromItems.length === newItems.length && fromItems.every(i => newItems.includes(i))) {
|
|
409
|
+
return /* Clicked back to where it started */
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return this.onSelectionChange(fromItems, newItems)
|
|
413
|
+
}.bind(this)).catch(function (e) {
|
|
414
|
+
if (fromItems && clickCount === this._selectionClickCount) {
|
|
415
|
+
setSelection(newItems, fromItems)
|
|
416
|
+
}
|
|
417
|
+
|
|
384
418
|
if (e == null) {
|
|
385
419
|
logger.debug('selection change rejected')
|
|
386
420
|
}
|
|
387
421
|
else {
|
|
388
422
|
throw e
|
|
389
423
|
}
|
|
390
|
-
})
|
|
424
|
+
}.bind(this))
|
|
391
425
|
}
|
|
392
426
|
|
|
393
427
|
_handleLongPress(item) {
|
package/js/main-ui/status.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import $ from '$qui/lib/jquery.module.js'
|
|
6
6
|
|
|
7
|
+
import Icon from '$qui/icons/icon.js'
|
|
7
8
|
import StockIcon from '$qui/icons/stock-icon.js'
|
|
8
9
|
import * as Icons from '$qui/icons/icons.js'
|
|
9
10
|
import * as Toast from '$qui/messages/toast.js'
|
|
@@ -60,8 +61,13 @@ let lastMessage = null
|
|
|
60
61
|
* @param {String} [message] an optional status message
|
|
61
62
|
*/
|
|
62
63
|
export function set(status, message = null) {
|
|
64
|
+
update(status, message)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function update(status, message, forceIcon = false) {
|
|
63
68
|
if (statusLockTimeoutHandle) {
|
|
64
|
-
|
|
69
|
+
forceIcon = forceIcon || Boolean(pendingStatusParams && pendingStatusParams.forceIcon)
|
|
70
|
+
pendingStatusParams = {status, message, forceIcon}
|
|
65
71
|
return
|
|
66
72
|
}
|
|
67
73
|
|
|
@@ -78,7 +84,7 @@ export function set(status, message = null) {
|
|
|
78
84
|
|
|
79
85
|
statusLockTimeoutHandle = null
|
|
80
86
|
if (pendingStatusParams) {
|
|
81
|
-
|
|
87
|
+
update(pendingStatusParams.status, pendingStatusParams.message, pendingStatusParams.forceIcon)
|
|
82
88
|
pendingStatusParams = null
|
|
83
89
|
}
|
|
84
90
|
|
|
@@ -144,12 +150,19 @@ export function set(status, message = null) {
|
|
|
144
150
|
variant = 'interactive'
|
|
145
151
|
}
|
|
146
152
|
|
|
147
|
-
new StockIcon({
|
|
153
|
+
let icon = new StockIcon({
|
|
148
154
|
name: iconName,
|
|
149
155
|
decoration: decoration,
|
|
150
156
|
variant: variant,
|
|
151
157
|
animation: animation
|
|
152
|
-
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/* The status is set again after every server response, mostly unchanged, and rendering an icon rebuilds it.
|
|
161
|
+
* A theme change keeps the same icon attributes but not the colors they stand for, so it forces a render. */
|
|
162
|
+
let iconElement = statusIndicator.find('.qui-icon')
|
|
163
|
+
if (forceIcon || !icon.equals(Icon.getFromElement(iconElement))) {
|
|
164
|
+
icon.applyTo(iconElement)
|
|
165
|
+
}
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
if (message) {
|
|
@@ -183,7 +196,7 @@ export function init() {
|
|
|
183
196
|
})
|
|
184
197
|
|
|
185
198
|
Theme.changeSignal.connect(function () {
|
|
186
|
-
|
|
199
|
+
update(lastStatus, lastMessage, /* forceIcon = */ true)
|
|
187
200
|
})
|
|
188
201
|
|
|
189
202
|
set(STATUS_OK)
|
package/js/utils/promise.js
CHANGED
|
@@ -32,6 +32,21 @@ export function later(timeout, arg) {
|
|
|
32
32
|
})
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Return a promise that resolves once the next frame has been painted, when reading element sizes is cheap because
|
|
37
|
+
* layout is already up to date.
|
|
38
|
+
* @alias qui.utils.promise.afterNextPaint
|
|
39
|
+
* @returns {Promise}
|
|
40
|
+
*/
|
|
41
|
+
export function afterNextPaint() {
|
|
42
|
+
/* A requestAnimationFrame() callback runs before that frame's layout; a timeout set from it runs after the paint */
|
|
43
|
+
return new Promise(function (resolve) {
|
|
44
|
+
window.requestAnimationFrame(function () {
|
|
45
|
+
setTimeout(resolve, 0)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
/**
|
|
36
51
|
* Run a promise with a timeout. If the promise is not fulfilled within the given timeout, the returned promise will be
|
|
37
52
|
* rejected with {@link qui.base.errors.TimeoutError}
|
package/js/widgets/widgets.js
CHANGED
|
@@ -29,6 +29,46 @@ function initButtons() {
|
|
|
29
29
|
})
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function initLongPress() {
|
|
33
|
+
/* A touch press held long enough to become a long press ends without a click, so the release looked like a
|
|
34
|
+
* tap that did nothing. Treat that release as a click, except in text fields and links, which have their own
|
|
35
|
+
* long-press menus. */
|
|
36
|
+
let longPressTarget = null
|
|
37
|
+
|
|
38
|
+
document.addEventListener('contextmenu', function (e) {
|
|
39
|
+
let ownMenu = e.target.closest('input, textarea, select, [contenteditable], a[href]')
|
|
40
|
+
longPressTarget = (e.pointerType === 'touch' && !ownMenu) ? e.target : null
|
|
41
|
+
if (longPressTarget) {
|
|
42
|
+
e.preventDefault()
|
|
43
|
+
}
|
|
44
|
+
}, {capture: true})
|
|
45
|
+
|
|
46
|
+
document.addEventListener('pointerup', function (e) {
|
|
47
|
+
let target = longPressTarget
|
|
48
|
+
longPressTarget = null
|
|
49
|
+
if (!target || e.pointerType !== 'touch') {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Touch pointers stay captured by the pressed element, so find where the finger actually left. Like a real
|
|
54
|
+
* click, the target is the closest element holding both the press and the release. */
|
|
55
|
+
let released = document.elementFromPoint(e.clientX, e.clientY)
|
|
56
|
+
while (target && !target.contains(released)) {
|
|
57
|
+
target = target.parentElement
|
|
58
|
+
}
|
|
59
|
+
if (!target || target === document.body || target === document.documentElement) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Let the release finish first, like a real click does */
|
|
64
|
+
setTimeout(() => target.click())
|
|
65
|
+
}, {capture: true, passive: true})
|
|
66
|
+
|
|
67
|
+
document.addEventListener('pointercancel', function () {
|
|
68
|
+
longPressTarget = null
|
|
69
|
+
}, {capture: true, passive: true})
|
|
70
|
+
}
|
|
71
|
+
|
|
32
72
|
/**
|
|
33
73
|
* Create a caption made of an icon and a text.
|
|
34
74
|
* @alias qui.widgets.makeIconTextCaption
|
|
@@ -54,4 +94,5 @@ export function makeIconTextCaption(icon, text) {
|
|
|
54
94
|
|
|
55
95
|
export function init() {
|
|
56
96
|
initButtons()
|
|
97
|
+
initLongPress()
|
|
57
98
|
}
|
package/less/base.less
CHANGED
|
@@ -58,14 +58,40 @@ div.qui-icon-label-view {
|
|
|
58
58
|
background: @background-color;
|
|
59
59
|
color: @interactive-color;
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/* The pressed shade is an overlay faded by opacity, which the compositor animates without repainting the row. A
|
|
62
|
+
* background transition repaints it on every frame, and slow devices feel that as tap lag. */
|
|
63
|
+
position: relative;
|
|
64
|
+
|
|
65
|
+
&::before {
|
|
66
|
+
content: '';
|
|
67
|
+
position: absolute;
|
|
68
|
+
top: 0;
|
|
69
|
+
right: 0;
|
|
70
|
+
bottom: 0;
|
|
71
|
+
left: 0;
|
|
62
72
|
background: @background-active-color;
|
|
73
|
+
opacity: 0;
|
|
74
|
+
transition: opacity @transition-duration ease;
|
|
75
|
+
pointer-events: none;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Keeps the icon and labels above the overlay */
|
|
79
|
+
& > * {
|
|
80
|
+
position: relative;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
&.active::before {
|
|
84
|
+
opacity: 1;
|
|
63
85
|
}
|
|
64
86
|
|
|
65
87
|
&.selected {
|
|
66
88
|
background: @interactive-color;
|
|
67
89
|
color: @background-color;
|
|
68
90
|
|
|
91
|
+
&::before {
|
|
92
|
+
background: @interactive-active-color;
|
|
93
|
+
}
|
|
94
|
+
|
|
69
95
|
& > div.labels > div.sub-label {
|
|
70
96
|
color: @background-color;
|
|
71
97
|
background-color: fade(@interactive-active-color, 50%);
|
|
@@ -73,11 +99,6 @@ div.qui-icon-label-view {
|
|
|
73
99
|
|
|
74
100
|
}
|
|
75
101
|
|
|
76
|
-
&.active.selected {
|
|
77
|
-
background: @interactive-active-color;
|
|
78
|
-
color: @background-color;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
}
|
package/less/pages/page.less
CHANGED
|
@@ -59,6 +59,18 @@ body.small-screen > div.qui-global-glass > div.qui-global-glass-container > div.
|
|
|
59
59
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/* On a large screen, pages fade in place instead of sliding, since animating left and width lays out every visible
|
|
63
|
+
* page on each frame. A page going away keeps its place until it has faded out; one coming in takes its place at once. */
|
|
64
|
+
body:NOT(.small-screen) > div.qui-main-container > div.qui-pages-container > div.qui-page {
|
|
65
|
+
transition: opacity @transition-duration ease-out,
|
|
66
|
+
width 0s linear @transition-duration,
|
|
67
|
+
left 0s linear @transition-duration;
|
|
68
|
+
|
|
69
|
+
&.visible {
|
|
70
|
+
transition: opacity @transition-duration ease-out;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
div.qui-page.current {
|
|
63
75
|
z-index: 9;
|
|
64
76
|
}
|
package/less/theme-dark.less
CHANGED
package/less/theme-light.less
CHANGED
package/package.json
CHANGED