terrier-engine 4.0.4 → 4.0.6

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 ADDED
@@ -0,0 +1,81 @@
1
+ import { Logger } from "tuff-core/logging"
2
+ import {Part, PartParent} from "tuff-core/parts"
3
+ import {TerrierPart} from "./parts"
4
+ import Tooltips from "./tooltips"
5
+ import Lightbox from "./lightbox"
6
+
7
+ // @ts-ignore
8
+ import logoUrl from './images/optimized/terrier-hub-logo-light.svg'
9
+ import Theme, {ThemeType} from "./theme"
10
+ import {ModalPart, ModalStackPart} from "./modals"
11
+ import {OverlayLayer, OverlayPart} from "./overlays"
12
+
13
+ const log = new Logger('App')
14
+ Logger.level = 'info'
15
+
16
+ /**
17
+ * Main application part that renders the entire page.
18
+ */
19
+ export abstract class TerrierApp<TT extends ThemeType> extends TerrierPart<{theme: Theme<TT>}, TT> {
20
+
21
+ _theme!: Theme<TT>
22
+
23
+ get theme(): Theme<TT> {
24
+ return this._theme
25
+ }
26
+
27
+ overlayPart!: OverlayPart
28
+
29
+ async init() {
30
+ this._theme = this.state.theme
31
+ this.overlayPart = this.makePart(OverlayPart, {})
32
+ log.info("Initialized")
33
+ }
34
+
35
+ load() {
36
+ // clear all overlays (i.e. dropdowns) whenever the page changes
37
+ this.clearOverlays()
38
+ }
39
+
40
+ update(root: HTMLElement) {
41
+ log.info(`Update`, root)
42
+ Tooltips.init(root)
43
+ Lightbox.init(root, this, 'body-content')
44
+ }
45
+
46
+
47
+ /// Overlays
48
+
49
+ makeOverlay<OverlayType extends Part<StateType>, StateType>(
50
+ constructor: { new(p: PartParent, id: string, state: StateType): OverlayType; },
51
+ state: StateType,
52
+ layer: OverlayLayer): OverlayType {
53
+ return this.overlayPart.makeLayer(constructor, state, layer)
54
+ }
55
+
56
+ clearOverlay(layer: OverlayLayer) {
57
+ this.overlayPart.clearLayer(layer)
58
+ this.lastDropdownTarget = undefined
59
+ }
60
+
61
+ clearOverlays() {
62
+ this.overlayPart.clearAll()
63
+ }
64
+
65
+
66
+ lastDropdownTarget?: HTMLElement
67
+
68
+
69
+ /// Modals
70
+
71
+ showModal<ModalType extends ModalPart<StateType, TT>, StateType>(constructor: { new(p: PartParent, id: string, state: StateType): ModalType; }, state: StateType): ModalType {
72
+ const modalStack =
73
+ (this.overlayPart.parts.modal as ModalStackPart<TT>)
74
+ ?? this.makeOverlay(ModalStackPart, {}, 'modal')
75
+ const modal = modalStack.pushModal(constructor, state)
76
+ modalStack.dirty()
77
+ return modal
78
+ }
79
+
80
+
81
+ }
package/dropdowns.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { Logger } from "tuff-core/logging"
2
+ import { untypedKey } from "tuff-core/messages"
3
+ import { unique } from "tuff-core/arrays"
4
+ import {PartTag, StatelessPart} from "tuff-core/parts"
5
+ import Overlays from "./overlays"
6
+ import {TerrierPart} from "./parts"
7
+ import Objects from "tuff-core/objects"
8
+ import {Action, ThemeType} from "./theme"
9
+
10
+ const log = new Logger('Dropdowns')
11
+
12
+ const clearDropdownKey = untypedKey()
13
+
14
+ /**
15
+ * Abstract base class for dropdown parts.
16
+ * Subclasses must implement the `renderContent()` method to render the dropdown content.
17
+ */
18
+ export abstract class Dropdown<T, TT extends ThemeType> extends TerrierPart<T, TT> {
19
+
20
+ parentPart?: StatelessPart
21
+
22
+ // the computed absolute position of the
23
+ left = 0
24
+ top = 0
25
+
26
+ async init() {
27
+ this.onClick(clearDropdownKey, _ => {
28
+ this.clear()
29
+ })
30
+ }
31
+
32
+ /**
33
+ * Removes itself from the DOM.
34
+ */
35
+ clear() {
36
+ log.info("Clearing dropdown")
37
+ this.app.clearOverlay('dropdown')
38
+ }
39
+
40
+ render(parent: PartTag) {
41
+ parent.div('.dropdown-content', content => {
42
+ this.renderContent(content)
43
+ })
44
+ }
45
+
46
+ /**
47
+ * Subclasses must implement this to render the actual dropdown content.
48
+ * @param parent
49
+ */
50
+ abstract renderContent(parent: PartTag): void
51
+
52
+ anchorTarget?: HTMLElement
53
+
54
+ /**
55
+ * Store the anchor target in order to compute the dropdown position when it's rendered.
56
+ * @param target
57
+ */
58
+ anchor(target: HTMLElement) {
59
+ log.info(`Anchoring dropdown to target`, target)
60
+ this.anchorTarget = target
61
+ }
62
+
63
+ update(_elem: HTMLElement) {
64
+ const content = _elem.querySelector('.dropdown-content')
65
+ if (this.anchorTarget && content) {
66
+ log.info(`Anchoring dropdown`, content, this.anchorTarget)
67
+ Overlays.anchorElement(content as HTMLElement, this.anchorTarget)
68
+ content.classList.add('show')
69
+ }
70
+ }
71
+
72
+ }
73
+
74
+ /**
75
+ * A concrete dropdown part that shows a list of actions.
76
+ */
77
+ export class ActionsDropdown<TT extends ThemeType> extends Dropdown<Array<Action<TT>>, TT> {
78
+ renderContent(parent: PartTag) {
79
+ // handle each key declared on the actions directly,
80
+ // then clear the dropdown and re-emit them on the parent part
81
+ const keys = unique(this.state.map(action => action.click?.key).filter(Objects.notNull))
82
+ for (const key of keys) {
83
+ this.onClick(key, m => {
84
+ this.app.clearOverlay('dropdown')
85
+ log.info(`Re-emitting ${key.id} message`, m, this.parentPart)
86
+ if (this.parentPart) {
87
+ this.parentPart.emit('click', key, m.event, m.data)
88
+ } else {
89
+ this.emit('click', key, m.event, m.data)
90
+ }
91
+ })
92
+ }
93
+ this.theme.renderActions(parent, this.state, {iconColor: 'white'})
94
+ }
95
+
96
+ }
package/fragments.ts ADDED
@@ -0,0 +1,396 @@
1
+ import {PartTag} from "tuff-core/parts"
2
+ import {AnchorTagAttrs, HtmlParentTag} from "tuff-core/html"
3
+ import Theme, {Action, Packet, ThemeType} from "./theme"
4
+ import {ActionLevel, PanelActions} from "./parts"
5
+
6
+ /**
7
+ * Base class for Panel and Card fragment builders.
8
+ */
9
+ abstract class ContentFragment<TT extends ThemeType> {
10
+ protected constructor(readonly prefix: string, readonly theme: Theme<TT>) {
11
+
12
+ }
13
+
14
+ protected _title?: string
15
+
16
+ /**
17
+ * @param t the title
18
+ * @param icon the optional icon
19
+ */
20
+ title(t: string, icon?: TT['icons']) {
21
+ this._title = t
22
+ this._icon = icon
23
+ return this
24
+ }
25
+
26
+ protected _icon?: TT['icons']
27
+
28
+ /**
29
+ * @param i the panel icon
30
+ */
31
+ icon(i: TT['icons']) {
32
+ this._icon = i
33
+ return this
34
+ }
35
+
36
+ protected _content?: (parent: HtmlParentTag) => void
37
+
38
+ /**
39
+ * @param fun a function that renders the panel content
40
+ */
41
+ content(fun: (parent: HtmlParentTag) => void) {
42
+ this._content = fun
43
+ return this
44
+ }
45
+
46
+ }
47
+
48
+
49
+ export class PanelFragment<TT extends ThemeType> extends ContentFragment<TT> {
50
+ constructor(theme: Theme<TT>) {
51
+ super('tt-panel', theme)
52
+ }
53
+
54
+ /// Actions
55
+
56
+ actions = {
57
+ primary: Array<Action<TT>>(),
58
+ secondary: Array<Action<TT>>(),
59
+ tertiary: Array<Action<TT>>()
60
+ }
61
+
62
+ /**
63
+ * Add an action to the panel.
64
+ * @param action the action to add
65
+ * @param level whether it's a primary, secondary, or tertiary action
66
+ */
67
+ addAction(action: Action<TT>, level: ActionLevel = 'primary') {
68
+ this.actions[level].push(action)
69
+ return this
70
+ }
71
+
72
+ /**
73
+ * Renders the panel into the given parent tag
74
+ * @param parent
75
+ */
76
+ render(parent: PartTag) {
77
+ return parent.div(this.prefix, panel => {
78
+ if (this._title?.length) {
79
+ panel.div(`.${this.prefix}-header`, header => {
80
+ header.h2(h2 => {
81
+ if (this._icon) {
82
+ this.theme.renderIcon(h2, this._icon, 'link')
83
+ }
84
+ h2.div('.title', {text: this._title})
85
+ })
86
+ this.theme.renderActions(header, this.actions.tertiary)
87
+ })
88
+ }
89
+ panel.div(`.${this.prefix}-content`, content => {
90
+ if (this._content) {
91
+ this._content(content)
92
+ }
93
+ })
94
+ panelActions(panel, this.actions, this.theme)
95
+ })
96
+ }
97
+
98
+ }
99
+
100
+ /**
101
+ * Render the primary and secondary actions to the bottom of a panel
102
+ * @param panel the .panel container
103
+ * @param actions the actions
104
+ */
105
+ function panelActions<TT extends ThemeType>(panel: PartTag, actions: PanelActions<TT>, theme: Theme<TT>) {
106
+ if (actions.primary.length || actions.secondary.length) {
107
+ panel.div('.panel-actions', actionsContainer => {
108
+ actionsContainer.div('.secondary-actions', secondaryContainer => {
109
+ theme.renderActions(secondaryContainer, actions.secondary, {iconColor: 'white', defaultClass: 'link'})
110
+ })
111
+ actionsContainer.div('.primary-actions', primaryContainer => {
112
+ theme.renderActions(primaryContainer, actions.primary, {iconColor: 'white'})
113
+ })
114
+ })
115
+ }
116
+ }
117
+
118
+
119
+ /**
120
+ * Cards are like panels except they don't have any actions and are themselves an anchor.
121
+ */
122
+ class CardFragment<TT extends ThemeType> extends ContentFragment<TT> {
123
+ constructor(theme: Theme<TT>) {
124
+ super('tt-card', theme)
125
+ }
126
+
127
+ private _href?: string
128
+
129
+ /**
130
+ * Sets the href of the anchor tag
131
+ * @param h
132
+ */
133
+ href(h: string) {
134
+ this._href = h
135
+ return this
136
+ }
137
+
138
+ /**
139
+ * Renders the card into the given parent tag
140
+ * @param parent
141
+ */
142
+ render(parent: PartTag) {
143
+ return parent.a({href: this._href}, this.prefix, panel => {
144
+ if (this._title?.length) {
145
+ panel.div(`.${this.prefix}-header`, header => {
146
+ if (this._icon) {
147
+ this.theme.renderIcon(header, this._icon, 'link')
148
+ }
149
+ header.h3({text: this._title})
150
+ })
151
+ }
152
+ panel.div(`.${this.prefix}-content`, content => {
153
+ if (this._content) {
154
+ this._content(content)
155
+ }
156
+ })
157
+ })
158
+ }
159
+
160
+ }
161
+
162
+
163
+ class LabeledValueFragment<TT extends ThemeType> extends ContentFragment<TT> {
164
+
165
+ constructor(theme: Theme<TT>) {
166
+ super('tt-labeled-value', theme)
167
+ }
168
+
169
+ private _value?: string
170
+ private _valueIcon?: TT['icons']
171
+ private _valueIconColor?: TT['colors']
172
+ private _valueClass?: string[]
173
+
174
+ private _href?: string
175
+ private _hrefTarget?: string
176
+
177
+ private _click?: Packet
178
+
179
+ private _tooltip?: string
180
+
181
+ value(value: string, icon?: TT['icons'], iconColor?: TT['colors']) {
182
+ this._value = value
183
+ this._valueIcon = icon
184
+ this._valueIconColor = iconColor
185
+ return this
186
+ }
187
+
188
+ valueClass(...classes: string[]) {
189
+ this._valueClass = classes
190
+ return this
191
+ }
192
+
193
+ href(value: string, target?: string) {
194
+ this._href = value
195
+ if (target) this._hrefTarget = target
196
+ return this
197
+ }
198
+
199
+ emitClick(packet?: Packet) {
200
+ this._click = packet
201
+ return this
202
+ }
203
+
204
+ /**
205
+ * Set the tooltip that will show for the value
206
+ * @param tooltip
207
+ */
208
+ tooltip(tooltip: string) {
209
+ this._tooltip = tooltip
210
+ return this
211
+ }
212
+
213
+ render(parent: PartTag) {
214
+ parent.div('.tt-labeled-value', container => {
215
+ if (this._title) {
216
+ container.div('.title', title => {
217
+ if (this._icon) {
218
+ this.theme.renderIcon(title, this._icon, 'link')
219
+ }
220
+ title.div({text: this._title})
221
+ })
222
+ }
223
+
224
+ container.div('.value', div => {
225
+ let valueBox: HtmlParentTag = div
226
+ if (this._href?.length) {
227
+ valueBox = div.a({ href: this._href, target: this._hrefTarget })
228
+ } else if (this._click) {
229
+ valueBox = div.a()
230
+ valueBox.emitClick(this._click.key, this._click.data ?? {})
231
+ }
232
+
233
+ if (this._valueClass?.length) div.class(...this._valueClass)
234
+ if (this._tooltip) valueBox.dataAttr("tooltip", this._tooltip)
235
+ if (this._value) {
236
+ if (this._valueIcon) {
237
+ this.theme.renderIcon(valueBox, this._valueIcon, this._valueIconColor ?? 'link')
238
+ }
239
+ valueBox.div('.value-text', {text: this._value})
240
+ }
241
+ else if (this._content) {
242
+ this._content(valueBox)
243
+ }
244
+ })
245
+ })
246
+ }
247
+ }
248
+
249
+ type ListValueDefinition = {
250
+ value: string
251
+ href?: string
252
+ hrefTarget?: string
253
+ tooltip?: string
254
+ }
255
+
256
+ class LabeledListFragment<TT extends ThemeType> extends ContentFragment<TT> {
257
+ private _values?: ListValueDefinition[]
258
+
259
+ constructor(theme: Theme<TT>) {
260
+ super('tt-labeled-list', theme)
261
+ }
262
+
263
+ render(parent: PartTag) {
264
+ parent.div('.tt-labeled-list', container => {
265
+ if (this._title) {
266
+ container.div('.title', title => {
267
+ if (this._icon) {
268
+ this.theme.renderIcon(title, this._icon, 'link')
269
+ }
270
+ title.div({ text: this._title })
271
+ })
272
+ }
273
+ container.div('.value.tt-flex.wrap', valueBox => {
274
+ for (const listItem of this._values ?? []) {
275
+ valueBox.div('.tt-flex.shrink.align-center.secondary', badge => {
276
+ if (listItem.tooltip) badge.dataAttr("tooltip", listItem.tooltip)
277
+
278
+ const anchorAttrs: AnchorTagAttrs = { classes: ['button'], text: listItem.value }
279
+ if (listItem.href?.length) {
280
+ anchorAttrs.classes?.push('secondary')
281
+ anchorAttrs.href = listItem.href
282
+ anchorAttrs.target = listItem.hrefTarget ?? '_blank'
283
+ } else {
284
+ anchorAttrs.classes?.push('readonly disabled')
285
+ }
286
+
287
+ badge.a(anchorAttrs)
288
+ })
289
+ }
290
+ })
291
+ })
292
+ }
293
+
294
+ values(values: ListValueDefinition[]) {
295
+ this._values = values
296
+ return this
297
+ }
298
+ }
299
+
300
+
301
+ /**
302
+ * Creates a new card fragment builder.
303
+ * Make sure to call `render()` in order to render it to a parent tag.
304
+ */
305
+ function card<TT extends ThemeType>(theme: Theme<TT>) {
306
+ return new CardFragment(theme)
307
+ }
308
+
309
+
310
+ /**
311
+ * Creates a new panel fragment builder.
312
+ * Make sure to call `render()` in order to render it to a parent tag.
313
+ */
314
+ function panel<TT extends ThemeType>(theme: Theme<TT>) {
315
+ return new PanelFragment(theme)
316
+ }
317
+
318
+ /**
319
+ * Creates a new labeled value fragment builder.
320
+ * Make sure to call `render()` in order to render it to a parent tag.
321
+ */
322
+ function labeledValue<TT extends ThemeType>(theme: Theme<TT>) {
323
+ return new LabeledValueFragment(theme)
324
+ }
325
+
326
+ function labeledList<TT extends ThemeType>(theme: Theme<TT>) {
327
+ return new LabeledListFragment(theme)
328
+ }
329
+
330
+ /**
331
+ * Create a new button in the parent.
332
+ * @param parent
333
+ * @param title
334
+ * @param icon
335
+ */
336
+ function button<TT extends ThemeType>(parent: PartTag, theme: Theme<TT>, title: string, icon?: TT['icons']) {
337
+ return parent.a('.tt-button', button => {
338
+ if (icon) {
339
+ theme.renderIcon(button, icon, 'white')
340
+ }
341
+ button.div('.title', {text: title})
342
+ })
343
+ }
344
+
345
+ /**
346
+ * Create a new simple value display in the parent.
347
+ * This is just some text with an optional icon that doesn't have a separate label.
348
+ * @param parent
349
+ * @param title
350
+ * @param icon
351
+ * @param iconColor
352
+ */
353
+ function simpleValue<TT extends ThemeType>(parent: PartTag, theme: Theme<TT>, title: string, icon?: TT['icons'], iconColor?: TT['colors']) {
354
+ return parent.div('.tt-simple-value.shrink', button => {
355
+ if (icon) {
356
+ theme.renderIcon(button, icon, iconColor || 'link')
357
+ }
358
+ button.div('.title', {text: title})
359
+ })
360
+ }
361
+
362
+ /**
363
+ * Helper to create a heading with an optional icon.
364
+ * @param parent
365
+ * @param title
366
+ * @param icon
367
+ */
368
+ function simpleHeading<TT extends ThemeType>(parent: PartTag, theme: Theme<TT>, title: string, icon?: TT['icons']) {
369
+ return parent.h3('.shrink', heading => {
370
+ if (icon) {
371
+ theme.renderIcon(heading, icon, 'link')
372
+ }
373
+ heading.div('.title', {text: title})
374
+ })
375
+ }
376
+
377
+
378
+
379
+
380
+ const Fragments = {
381
+ ContentFragment,
382
+ panel,
383
+ PanelFragment,
384
+ card,
385
+ CardFragment,
386
+ labeledValue,
387
+ LabeledValueFragment,
388
+ labeledList,
389
+ LabeledListFragment,
390
+ button,
391
+ simpleValue,
392
+ simpleHeading,
393
+ panelActions
394
+ }
395
+
396
+ export default Fragments
package/lightbox.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { Logger } from "tuff-core/logging"
2
+ import { untypedKey } from "tuff-core/messages"
3
+ import {Part, PartTag} from "tuff-core/parts"
4
+ import {TerrierApp} from "./app"
5
+
6
+ const log = new Logger('Lightbox')
7
+
8
+ ////////////////////////////////////////////////////////////////////////////////
9
+ // Global Event Listener
10
+ ////////////////////////////////////////////////////////////////////////////////
11
+
12
+ /**
13
+ * Initializes a global event listener for image elements contained in the `containerClass`
14
+ * @param root
15
+ * @param containerClass
16
+ */
17
+ function init(root: HTMLElement, app: TerrierApp<any>, containerClass: string) {
18
+ log.info("Init", root)
19
+ root.addEventListener("click", evt => {
20
+ if (!(evt.target instanceof HTMLElement) || evt.target.tagName != 'IMG') {
21
+ return
22
+ }
23
+ const elem = evt.target as HTMLImageElement
24
+
25
+ // filter the target by the container class
26
+ let show = false
27
+ for (const ancestor of evt.composedPath()) {
28
+ if (ancestor instanceof HTMLElement && ancestor.classList.contains(containerClass)) {
29
+ show = true
30
+ }
31
+ }
32
+ if (!show) {
33
+ return
34
+ }
35
+
36
+ const src = elem.src
37
+ log.info(`Clicked on lightbox image ${src}`, evt, elem)
38
+ showPart(app, {src})
39
+
40
+ }, {capture: true})
41
+ }
42
+
43
+
44
+ ////////////////////////////////////////////////////////////////////////////////
45
+ // Part
46
+ ////////////////////////////////////////////////////////////////////////////////
47
+
48
+ type LightboxState = { src: string }
49
+
50
+ function showPart(app: TerrierApp<any>, state: LightboxState) {
51
+ app.makeOverlay(LightboxPart, {app,...state}, 'lightbox')
52
+ }
53
+
54
+ const closeKey = untypedKey()
55
+
56
+ class LightboxPart extends Part<LightboxState & {app: TerrierApp<any>}> {
57
+
58
+ async init() {
59
+ this.onClick(closeKey, _ => {
60
+ this.close()
61
+ })
62
+ }
63
+
64
+ render(parent: PartTag) {
65
+ parent.div('.scroller', scroller => {
66
+ scroller.img({src: this.state.src})
67
+ })
68
+ .emitClick(closeKey)
69
+ }
70
+
71
+ update(_elem: HTMLElement) {
72
+ setTimeout(_ => {
73
+ _elem.classList.add('active') // start css fade in
74
+ }, 10)
75
+ }
76
+
77
+ close() {
78
+ this.element?.classList.remove('active')
79
+ setTimeout(_ => {
80
+ this.state.app.clearOverlay('lightbox')
81
+ }, 500)
82
+ }
83
+ }
84
+
85
+
86
+
87
+ ////////////////////////////////////////////////////////////////////////////////
88
+ // Export
89
+ ////////////////////////////////////////////////////////////////////////////////
90
+
91
+ const Lightbox = {
92
+ init
93
+ }
94
+
95
+ export default Lightbox