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/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.3",
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.24.1"
17
+ "tuff-core": "^0.25.0"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@types/node": "^18.16.3",