terrier-engine 4.0.3 → 4.0.5
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/app.ts +81 -0
- package/db-client.ts +3 -3
- package/dropdowns.ts +96 -0
- package/fragments.ts +391 -0
- package/lightbox.ts +95 -0
- package/loading.ts +70 -0
- package/modals.ts +154 -0
- package/overlays.ts +234 -0
- package/package.json +3 -2
- package/parts.ts +439 -0
- package/theme.ts +89 -0
- package/toasts.ts +77 -0
- package/tooltips.ts +65 -0
package/loading.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createElement } from "tuff-core/html"
|
|
2
|
+
import Time from "tuff-core/time"
|
|
3
|
+
import Theme, {ThemeType} from "./theme";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const overlayClass = 'loading-overlay'
|
|
8
|
+
|
|
9
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
10
|
+
// Overlay
|
|
11
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
12
|
+
|
|
13
|
+
function getOverlay(container: Element): HTMLDivElement | null {
|
|
14
|
+
const elems = container.getElementsByClassName(overlayClass)
|
|
15
|
+
if (elems.length) {
|
|
16
|
+
return elems[0] as HTMLDivElement
|
|
17
|
+
}
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createOverlay<TT extends ThemeType>(theme: Theme<TT>): HTMLDivElement {
|
|
22
|
+
return createElement('div', div => {
|
|
23
|
+
div.class(overlayClass)
|
|
24
|
+
const loaderSrc = theme.getLoaderSrc()
|
|
25
|
+
if (loaderSrc?.length) {
|
|
26
|
+
div.img({src: loaderSrc})
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates and shows a loading overlay in the given container.
|
|
33
|
+
* @param container
|
|
34
|
+
*/
|
|
35
|
+
function showOverlay<TT extends ThemeType>(container: Element, theme: Theme<TT>) {
|
|
36
|
+
const existingOverlay = getOverlay(container)
|
|
37
|
+
if (existingOverlay) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const overlay = createOverlay(theme)
|
|
41
|
+
container.append(overlay)
|
|
42
|
+
Time.wait(0).then(() => overlay.classList.add("active")) // start css fade in
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Removes the loading overlay (if present) from the given container.
|
|
47
|
+
* @param container
|
|
48
|
+
*/
|
|
49
|
+
function removeOverlay(container: Element) {
|
|
50
|
+
const overlay = getOverlay(container)
|
|
51
|
+
if (overlay) {
|
|
52
|
+
overlay.classList.remove('active')
|
|
53
|
+
Time.wait(500).then(() => {
|
|
54
|
+
overlay.remove()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
61
|
+
// Export
|
|
62
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
63
|
+
|
|
64
|
+
const Loading = {
|
|
65
|
+
getOverlay,
|
|
66
|
+
showOverlay,
|
|
67
|
+
removeOverlay
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default Loading
|
package/modals.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Logger } from "tuff-core/logging"
|
|
2
|
+
import { untypedKey } from "tuff-core/messages"
|
|
3
|
+
import {ContentPart, TerrierPart} from "./parts"
|
|
4
|
+
import {PartParent, PartTag} from "tuff-core/parts"
|
|
5
|
+
import {ThemeType} from "./theme";
|
|
6
|
+
|
|
7
|
+
const log = new Logger('Modals')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
11
|
+
// Base Part
|
|
12
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Base class for all modals.
|
|
16
|
+
* Since it extends ContentPart, all of the same title/action methods
|
|
17
|
+
* available for pages can be used in modals as well.
|
|
18
|
+
*/
|
|
19
|
+
export abstract class ModalPart<T, TT extends ThemeType> extends ContentPart<T, TT> {
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
get parentClasses(): Array<string> {
|
|
23
|
+
return ['modal-part', 'tt-typography']
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected pop() {
|
|
27
|
+
this.emitMessage(modalPopKey, {}, { scope: 'bubble' })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
render(parent: PartTag) {
|
|
31
|
+
parent.div('.modal-header', header => {
|
|
32
|
+
if (this._icon) {
|
|
33
|
+
this.theme.renderIcon(header, this._icon, 'secondary')
|
|
34
|
+
}
|
|
35
|
+
header.h2({text: this._title || 'Call setTitle()'})
|
|
36
|
+
this.theme.renderActions(header, this.getActions('tertiary'), {defaultClass: 'secondary'})
|
|
37
|
+
header.a('.close-modal', closeButton => {
|
|
38
|
+
this.theme.renderCloseIcon(closeButton)
|
|
39
|
+
}).emitClick(modalPopKey)
|
|
40
|
+
})
|
|
41
|
+
parent.div('.modal-content', content => {
|
|
42
|
+
this.renderContent(content)
|
|
43
|
+
})
|
|
44
|
+
const secondaryActions = this.getActions('secondary')
|
|
45
|
+
const primaryActions = this.getActions('primary')
|
|
46
|
+
if (secondaryActions.length || primaryActions.length) {
|
|
47
|
+
parent.div('.modal-actions', actions => {
|
|
48
|
+
actions.div('.secondary-actions', container => {
|
|
49
|
+
this.theme.renderActions(container, secondaryActions, {iconColor: 'white', defaultClass: 'secondary'})
|
|
50
|
+
})
|
|
51
|
+
actions.div('.primary-actions', container => {
|
|
52
|
+
this.theme.renderActions(container, primaryActions, {iconColor: 'white', defaultClass: 'primary'})
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
62
|
+
// Stack
|
|
63
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Emit this key from anywhere inside a modal to pop it off the stack.
|
|
67
|
+
*/
|
|
68
|
+
export const modalPopKey = untypedKey()
|
|
69
|
+
|
|
70
|
+
export class ModalStackPart<TT extends ThemeType> extends TerrierPart<{}, TT> {
|
|
71
|
+
|
|
72
|
+
displayClass: 'show' | 'hide' = 'show'
|
|
73
|
+
|
|
74
|
+
async init() {
|
|
75
|
+
this.onClick(modalPopKey, _ => this.pop())
|
|
76
|
+
this.listenMessage(modalPopKey, _ => this.pop())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
modals: ModalPart<unknown, TT>[] = []
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pops the last modal off the stack and removes itself if it's empty.
|
|
83
|
+
*/
|
|
84
|
+
pop() {
|
|
85
|
+
log.info("Popping Modal")
|
|
86
|
+
if (this.modals.length == 0) {
|
|
87
|
+
this.close()
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const modal = this.modals.pop()
|
|
91
|
+
if (modal) {
|
|
92
|
+
this.removeChild(modal)
|
|
93
|
+
}
|
|
94
|
+
if (this.modals.length == 0) {
|
|
95
|
+
this.close()
|
|
96
|
+
}
|
|
97
|
+
this.dirty()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
close() {
|
|
101
|
+
const elem = this.element
|
|
102
|
+
this.displayClass = 'hide'
|
|
103
|
+
if (elem) {
|
|
104
|
+
const stackElem = elem.querySelector('.tt-modal-stack')
|
|
105
|
+
if (stackElem) {
|
|
106
|
+
stackElem.classList.remove('show')
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pushes a new modal to the stack
|
|
113
|
+
* @param constructor the modal class
|
|
114
|
+
* @param state the modal's state
|
|
115
|
+
*/
|
|
116
|
+
pushModal<ModalType extends ModalPart<StateType, TT>, StateType>(constructor: { new(p: PartParent, id: string, state: StateType): ModalType; }, state: StateType): ModalType {
|
|
117
|
+
log.info(`Making modal`, constructor.name)
|
|
118
|
+
const modal = this.makePart(constructor, state)
|
|
119
|
+
this.modals.push(modal)
|
|
120
|
+
this.displayClass = 'show'
|
|
121
|
+
this.dirty()
|
|
122
|
+
return modal
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
render(parent: PartTag) {
|
|
127
|
+
const classes = [`stack-${this.modals.length}`]
|
|
128
|
+
if (this.displayClass == 'show') {
|
|
129
|
+
classes.push(this.displayClass)
|
|
130
|
+
}
|
|
131
|
+
parent.div('.tt-modal-stack', {classes}, stack => {
|
|
132
|
+
stack.div('.modal-container', container => {
|
|
133
|
+
this.eachChild(part => {
|
|
134
|
+
container.part(part)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
update(root: HTMLElement) {
|
|
142
|
+
const stack = root.querySelector('.tt-modal-stack')
|
|
143
|
+
if (stack && this.displayClass == 'show') {
|
|
144
|
+
setTimeout(
|
|
145
|
+
() => {
|
|
146
|
+
stack.classList.add('show')
|
|
147
|
+
stack.querySelectorAll('.modal-part').forEach(modal => {
|
|
148
|
+
modal.classList.add('show')
|
|
149
|
+
})
|
|
150
|
+
}, 10
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
package/overlays.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Part, PartParent, PartTag, StatelessPart, NoState } from "tuff-core/parts"
|
|
2
|
+
import { Size, Box, Side } from "tuff-core/box"
|
|
3
|
+
import { Logger } from "tuff-core/logging"
|
|
4
|
+
|
|
5
|
+
const log = new Logger('Overlays')
|
|
6
|
+
|
|
7
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
8
|
+
// Part
|
|
9
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
10
|
+
|
|
11
|
+
const OverlayLayers = ['modal', 'dropdown', 'lightbox', 'jump'] as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Which overlay layer to use for an overlay part.
|
|
15
|
+
* There can only be one part per layer.
|
|
16
|
+
*/
|
|
17
|
+
export type OverlayLayer = typeof OverlayLayers[number]
|
|
18
|
+
|
|
19
|
+
export class OverlayPart extends Part<NoState> {
|
|
20
|
+
|
|
21
|
+
parts: {[layer in OverlayLayer]?: StatelessPart} = {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a part at the given layer.
|
|
25
|
+
* Discards the old part at that layer, if there was one.
|
|
26
|
+
* @param constructor
|
|
27
|
+
* @param state
|
|
28
|
+
* @param layer
|
|
29
|
+
*/
|
|
30
|
+
makeLayer<PartType extends Part<StateType>, StateType>(
|
|
31
|
+
constructor: { new(p: PartParent, id: string, state: StateType): PartType; },
|
|
32
|
+
state: StateType,
|
|
33
|
+
layer: OverlayLayer): PartType {
|
|
34
|
+
const part = this.makePart(constructor, state)
|
|
35
|
+
this.clearLayer(layer)
|
|
36
|
+
this.parts[layer] = part
|
|
37
|
+
return part
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Clear a single overlay layer.
|
|
42
|
+
* @param layer
|
|
43
|
+
*/
|
|
44
|
+
clearLayer(layer: OverlayLayer) {
|
|
45
|
+
const layerPart = this.parts[layer]
|
|
46
|
+
if (layerPart) {
|
|
47
|
+
this.removeChild(layerPart)
|
|
48
|
+
this.parts[layer] = undefined
|
|
49
|
+
}
|
|
50
|
+
this.dirty()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear all overlay layers.
|
|
55
|
+
*/
|
|
56
|
+
clearAll() {
|
|
57
|
+
for (const layer of OverlayLayers) {
|
|
58
|
+
this.clearLayer(layer)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
render(parent: PartTag) {
|
|
63
|
+
for (const layer of OverlayLayers) {
|
|
64
|
+
const part = this.parts[layer]
|
|
65
|
+
if (part) {
|
|
66
|
+
parent.div(layer).part(part)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
75
|
+
// Anchoring
|
|
76
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
77
|
+
|
|
78
|
+
type AnchorResult = {
|
|
79
|
+
left: number
|
|
80
|
+
top: number
|
|
81
|
+
width?: number,
|
|
82
|
+
height?: number,
|
|
83
|
+
valid: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clampHorizontal(result: AnchorResult, size: Size, container: Size) {
|
|
87
|
+
if (container.width < size.width) {
|
|
88
|
+
result.width = container.width
|
|
89
|
+
}
|
|
90
|
+
const rightOffset = container.width - (result.width ?? size.width) // don't hang off the right side of the viewport
|
|
91
|
+
result.left = Math.max(0, Math.min(result.left, rightOffset)) // don't hang off the left side of the viewport
|
|
92
|
+
if (result.top < 0 || result.top+size.height > container.height) {
|
|
93
|
+
result.valid = false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function anchorBoxBottom(size: Size, anchor: Box, container: Size): AnchorResult {
|
|
98
|
+
const result = {
|
|
99
|
+
top: anchor.y + anchor.height,
|
|
100
|
+
left: anchor.x + anchor.width / 2 - size.width / 2,
|
|
101
|
+
valid: true
|
|
102
|
+
}
|
|
103
|
+
clampHorizontal(result, size, container)
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function anchorBoxTop(size: Size, anchor: Box, container: Size): AnchorResult {
|
|
108
|
+
const result = {
|
|
109
|
+
top: anchor.y - size.height,
|
|
110
|
+
left: anchor.x + anchor.width / 2 - size.width / 2,
|
|
111
|
+
valid: true
|
|
112
|
+
}
|
|
113
|
+
clampHorizontal(result, size, container)
|
|
114
|
+
return result
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function clampVertical(result: AnchorResult, size: Size, container: Size) {
|
|
118
|
+
if (container.height < size.height) {
|
|
119
|
+
result.height = container.height
|
|
120
|
+
}
|
|
121
|
+
const bottomOffset = container.height - (result.height ?? size.height) // don't hang off the bottom of the viewport
|
|
122
|
+
result.top = Math.max(0, Math.min(result.top, bottomOffset)) // don't hang off the top of the viewport
|
|
123
|
+
if (result.left < 0 || result.left + size.width > container.width) {
|
|
124
|
+
result.valid = false
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function anchorBoxLeft(size: Size, anchor: Box, container: Size): AnchorResult {
|
|
129
|
+
const result = {
|
|
130
|
+
top: anchor.y + anchor.height/2 - size.height / 2,
|
|
131
|
+
left: anchor.x - size.width,
|
|
132
|
+
valid: true
|
|
133
|
+
}
|
|
134
|
+
clampVertical(result, size, container)
|
|
135
|
+
return result
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function anchorBoxRight(size: Size, anchor: Box, container: Size): AnchorResult {
|
|
139
|
+
const result = {
|
|
140
|
+
top: anchor.y + anchor.height/2 - size.height / 2,
|
|
141
|
+
left: anchor.x + anchor.width,
|
|
142
|
+
valid: true
|
|
143
|
+
}
|
|
144
|
+
clampVertical(result, size, container)
|
|
145
|
+
return result
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function anchorBoxSide(size: Size, anchor: Box, container: Size, side: Side): AnchorResult {
|
|
149
|
+
switch (side) {
|
|
150
|
+
case 'bottom':
|
|
151
|
+
return anchorBoxBottom(size, anchor, container)
|
|
152
|
+
case 'top':
|
|
153
|
+
return anchorBoxTop(size, anchor, container)
|
|
154
|
+
case 'left':
|
|
155
|
+
return anchorBoxLeft(size, anchor, container)
|
|
156
|
+
case 'right':
|
|
157
|
+
return anchorBoxRight(size, anchor, container)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type AnchorOptions = {
|
|
162
|
+
preferredSide: Side
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Computes an anchored position for a box of the given size to the anchor.
|
|
167
|
+
* @param size the size of the box to be anchored
|
|
168
|
+
* @param anchor the anchor box itself
|
|
169
|
+
* @param container the size of the container (presumably the window)
|
|
170
|
+
* @param options
|
|
171
|
+
* @return a left/top combination representing the new anchored position
|
|
172
|
+
*/
|
|
173
|
+
function anchorBox(size: Size, anchor: Box, container: Size, options: AnchorOptions): AnchorResult {
|
|
174
|
+
// try the preferred side first
|
|
175
|
+
const preferredResult = anchorBoxSide(size, anchor, container, options.preferredSide)
|
|
176
|
+
if (preferredResult.valid) {
|
|
177
|
+
return preferredResult
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// try other sides
|
|
181
|
+
for (const side of ['bottom', 'top', 'right', 'left'] as const) {
|
|
182
|
+
if (side == options.preferredSide) {
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
const result = anchorBoxSide(size, anchor, container, side)
|
|
186
|
+
if (result.valid) {
|
|
187
|
+
return result
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// none of the sides were valid, just use the preferred one, I guess
|
|
192
|
+
return preferredResult
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Anchors one element to the side of another.
|
|
197
|
+
* @param elem the element to reposition
|
|
198
|
+
* @param anchor the anchor element
|
|
199
|
+
*/
|
|
200
|
+
function anchorElement(elem: HTMLElement, anchor: HTMLElement) {
|
|
201
|
+
// Sometimes the actual width and height of the rendered element is incorrect before we set the style attribute.
|
|
202
|
+
// Setting the style attribute first forces the browser to re-calculate the size of the element so that we can use
|
|
203
|
+
// the "real" size to calculate where to anchor the element.
|
|
204
|
+
elem.setAttribute('style', 'top:0;left:0;')
|
|
205
|
+
|
|
206
|
+
const elemSize = {
|
|
207
|
+
width: elem.offsetWidth,
|
|
208
|
+
height: elem.offsetHeight
|
|
209
|
+
}
|
|
210
|
+
log.debug(`Anchoring element`, elem, anchor)
|
|
211
|
+
const rect = anchor.getBoundingClientRect()
|
|
212
|
+
const win = {width: window.innerWidth, height: window.innerHeight }
|
|
213
|
+
const anchorResult = anchorBox(elemSize, rect, win, {preferredSide: 'bottom'})
|
|
214
|
+
|
|
215
|
+
let styleString = ""
|
|
216
|
+
for (const key of ['top', 'left', 'width', 'height'] as const) {
|
|
217
|
+
if (anchorResult[key] != null) {
|
|
218
|
+
styleString += `${key}: ${anchorResult[key]}px;`
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
elem.setAttribute('style', styleString)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
226
|
+
// Export
|
|
227
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
228
|
+
|
|
229
|
+
const Overlays = {
|
|
230
|
+
anchorElement,
|
|
231
|
+
anchorBox
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default Overlays
|
package/package.json
CHANGED
|
@@ -4,16 +4,17 @@
|
|
|
4
4
|
"files": [
|
|
5
5
|
"*"
|
|
6
6
|
],
|
|
7
|
-
"version": "4.0.
|
|
7
|
+
"version": "4.0.5",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "https://github.com/Terrier-Tech/terrier-engine"
|
|
11
11
|
},
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"scripts" : {
|
|
14
|
+
"check" : "tsc -p app/frontend -noEmit"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|
|
16
|
-
"tuff-core": "^0.
|
|
17
|
+
"tuff-core": "^0.25.0"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
20
|
"@types/node": "^18.16.3",
|