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/parts.ts ADDED
@@ -0,0 +1,439 @@
1
+ import { Logger } from "tuff-core/logging"
2
+ import { Part } from "tuff-core/parts"
3
+ import {PartParent, PartTag, NoState} from "tuff-core/parts"
4
+ import Fragments from "./fragments"
5
+ import {Dropdown} from "./dropdowns"
6
+ import {TerrierApp} from "./app"
7
+ import Loading from "./loading"
8
+ import Theme, {Action, ThemeType} from "./theme"
9
+ import Toasts, {ToastOptions} from "./toasts";
10
+
11
+ const log = new Logger('Parts')
12
+
13
+ export type PanelActions<TT extends ThemeType> = {
14
+ primary: Array<Action<TT>>
15
+ secondary: Array<Action<TT>>
16
+ tertiary: Array<Action<TT>>
17
+ }
18
+
19
+ export type ActionLevel = keyof PanelActions<any>
20
+
21
+
22
+ ////////////////////////////////////////////////////////////////////////////////
23
+ // Terrier Part
24
+ ////////////////////////////////////////////////////////////////////////////////
25
+
26
+ /**
27
+ * Base class for ALL parts in a Terrier application.
28
+ */
29
+ export abstract class TerrierPart<T, TT extends ThemeType> extends Part<T> {
30
+
31
+ get app(): TerrierApp<TT> {
32
+ return this.root as TerrierApp<TT> // this should always be true
33
+ }
34
+
35
+ get theme(): Theme<TT> {
36
+ return this.app.theme
37
+ }
38
+
39
+ /// Loading
40
+
41
+ /**
42
+ * This can be overloaded if the loading overlay should go
43
+ * somewhere other than the part's root element.
44
+ */
45
+ getLoadingContainer(): Element | null | undefined {
46
+ return this.element
47
+ }
48
+
49
+
50
+ /**
51
+ * Shows the loading animation on top of the part.
52
+ */
53
+ startLoading() {
54
+ const elem = this.getLoadingContainer()
55
+ if (!elem) {
56
+ return
57
+ }
58
+ Loading.showOverlay(elem, this.theme)
59
+ }
60
+
61
+ /**
62
+ * Removes the loading animation from the part.
63
+ */
64
+ stopLoading() {
65
+ const elem = this.getLoadingContainer()
66
+ if (!elem) {
67
+ return
68
+ }
69
+ Loading.removeOverlay(elem)
70
+ }
71
+
72
+ /**
73
+ * Shows the loading overlay until the given function completes (either returns successfully or throws an exception)
74
+ * @param func
75
+ */
76
+ showLoading(func: () => void): void
77
+ showLoading(func: () => Promise<void>): Promise<void>
78
+ showLoading(func: () => void | Promise<void>): void | Promise<void> {
79
+ this.startLoading()
80
+ let stopImmediately = true
81
+ try {
82
+ const res = func()
83
+ if (res) {
84
+ stopImmediately = false
85
+ return res.finally(() => {
86
+ this.stopLoading()
87
+ })
88
+ }
89
+ } finally {
90
+ if (stopImmediately) {
91
+ this.stopLoading()
92
+ }
93
+ }
94
+ }
95
+
96
+
97
+ /// Toasts
98
+
99
+ /**
100
+ * Shows a toast message in a bubble in the upper right corner.
101
+ * @param message the message text
102
+ * @param options
103
+ */
104
+ showToast(message: string, options: ToastOptions<TT>) {
105
+ Toasts.show(message, options, this.theme)
106
+ }
107
+
108
+ }
109
+
110
+
111
+ ////////////////////////////////////////////////////////////////////////////////
112
+ // Content Part
113
+ ////////////////////////////////////////////////////////////////////////////////
114
+
115
+ /**
116
+ * Base class for all Parts that render some main content, like pages, panels, and modals.
117
+ */
118
+ export abstract class ContentPart<T, TT extends ThemeType> extends TerrierPart<T, TT> {
119
+
120
+ /**
121
+ * All ContentParts must implement this to render their actual content.
122
+ * @param parent
123
+ */
124
+ abstract renderContent(parent: PartTag): void
125
+
126
+ get app(): TerrierApp<TT> {
127
+ return this.root as TerrierApp<TT> // this should always be true
128
+ }
129
+
130
+
131
+ protected _title = ''
132
+
133
+ /**
134
+ * Sets the page, panel, or modal title.
135
+ * @param title
136
+ */
137
+ setTitle(title: string) {
138
+ this._title = title
139
+ }
140
+
141
+ protected _icon: TT['icons'] | null = null
142
+
143
+ setIcon(icon: TT['icons']) {
144
+ this._icon = icon
145
+ }
146
+
147
+ protected _breadcrumbClasses: string[] = []
148
+
149
+ addBreadcrumbClass(c: string) {
150
+ this._breadcrumbClasses.push(c)
151
+ }
152
+
153
+
154
+ /// Actions
155
+
156
+ // stored actions can be either an action object or a reference to a named action
157
+ actions = {
158
+ primary: Array<Action<TT> | string>(),
159
+ secondary: Array<Action<TT> | string>(),
160
+ tertiary: Array<Action<TT> | string>()
161
+ }
162
+
163
+ namedActions: Record<string, { action: Action<TT>, level: ActionLevel }> = {}
164
+
165
+ /**
166
+ * Add an action to the part, or replace a named action if it already exists.
167
+ * @param action the action to add
168
+ * @param level whether it's a primary, secondary, or tertiary action
169
+ * @param name a name to be given to this action, so it can be accessed later
170
+ */
171
+ addAction(action: Action<TT>, level: ActionLevel = 'primary', name?: string) {
172
+ if (name?.length) {
173
+ if (name in this.namedActions) {
174
+ const currentLevel = this.namedActions[name].level
175
+ if (level != currentLevel) {
176
+ const index = this.actions[currentLevel].indexOf(name)
177
+ this.actions[currentLevel].splice(index, 1)
178
+ this.actions[level].push(name)
179
+ }
180
+ this.namedActions[name].action = action
181
+ } else {
182
+ this.namedActions[name] = { action, level }
183
+ this.actions[level].push(name)
184
+ }
185
+ } else {
186
+ this.actions[level].push(action)
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Returns the action definition for the action with the given name, or undefined if there is no action with that name
192
+ * @param name
193
+ */
194
+ getNamedAction(name: string): Action<TT> | undefined {
195
+ return this.namedActions[name].action
196
+ }
197
+
198
+ /**
199
+ * Removes the action with the given name
200
+ * @param name
201
+ */
202
+ removeNamedAction(name: string) {
203
+ if (!(name in this.namedActions)) return
204
+ const level = this.actions[this.namedActions[name].level]
205
+ delete this.namedActions[name]
206
+ const actionIndex = level.indexOf(name)
207
+ if (actionIndex >= 0) {
208
+ level.splice(actionIndex, 1)
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Clears the actions for this part
214
+ * @param level whether to clear the primary, secondary, or both sets of actions
215
+ */
216
+ clearActions(level: ActionLevel) {
217
+ for (const action of this.actions[level]) {
218
+ if (typeof action === 'string') {
219
+ delete this.namedActions[action]
220
+ }
221
+ }
222
+ this.actions[level] = []
223
+ }
224
+
225
+ getAllActions(): PanelActions<TT> {
226
+ return {
227
+ primary: this.getActions('primary'),
228
+ secondary: this.getActions('secondary'),
229
+ tertiary: this.getActions('tertiary'),
230
+ }
231
+ }
232
+
233
+ getActions(level: ActionLevel): Action<TT>[] {
234
+ return this.actions[level].map(action => {
235
+ return (typeof action === 'string') ? this.namedActions[action].action : action
236
+ })
237
+ }
238
+
239
+
240
+ /// Dropdowns
241
+
242
+ /**
243
+ * Shows the given dropdown part on the page.
244
+ * It's generally better to call `toggleDropdown` instead so that the dropdown will be
245
+ * hidden upon a subsequent click on the target.
246
+ * @param constructor a constructor for a dropdown part
247
+ * @param state the dropdown's state
248
+ * @param target the target element around which to show the dropdown
249
+ */
250
+ makeDropdown<DropdownType extends Dropdown<DropdownStateType, TT>, DropdownStateType>(
251
+ constructor: {new(p: PartParent, id: string, state: DropdownStateType): DropdownType;},
252
+ state: DropdownStateType,
253
+ target: EventTarget | null) {
254
+ if (!(target && target instanceof HTMLElement)) {
255
+ throw "Trying to show a dropdown without an element target!"
256
+ }
257
+ const dropdown = this.app.makeOverlay(constructor, state, 'dropdown') as Dropdown<DropdownStateType, TT>
258
+ dropdown.parentPart = this
259
+ dropdown.anchor(target)
260
+ this.app.lastDropdownTarget = target
261
+ }
262
+
263
+ clearDropdown() {
264
+ this.app.clearOverlay('dropdown')
265
+ }
266
+
267
+ /**
268
+ * Calls `makeDropdown` only if there's not a dropdown currently originating from the target.
269
+ * @param constructor a constructor for a dropdown part
270
+ * @param state the dropdown's state
271
+ * @param target the target element around which to show the dropdown
272
+ */
273
+ toggleDropdown<DropdownType extends Dropdown<DropdownStateType, TT>, DropdownStateType>(
274
+ constructor: { new(p: PartParent, id: string, state: DropdownStateType): DropdownType; },
275
+ state: DropdownStateType,
276
+ target: EventTarget | null) {
277
+ if (target && target instanceof HTMLElement && target == this.app.lastDropdownTarget) {
278
+ this.clearDropdown()
279
+ } else {
280
+ this.makeDropdown(constructor, state, target)
281
+ }
282
+ }
283
+
284
+ }
285
+
286
+
287
+ ////////////////////////////////////////////////////////////////////////////////
288
+ // Page
289
+ ////////////////////////////////////////////////////////////////////////////////
290
+
291
+ /**
292
+ * Whether some content should be constrained to a reasonable width or span the entire screen.
293
+ */
294
+ export type ContentWidth = "normal" | "wide"
295
+
296
+
297
+ /**
298
+ * A part that renders content to a full page.
299
+ */
300
+ export abstract class PagePart<T, TT extends ThemeType> extends ContentPart<T, TT> {
301
+
302
+ /// Breadcrumbs
303
+
304
+ private _breadcrumbs = Array<Action<TT>>()
305
+
306
+ addBreadcrumb(crumb: Action<TT>) {
307
+ this._breadcrumbs.push(crumb)
308
+ }
309
+
310
+
311
+ /**
312
+ * Sets both the page title and the last breadcrumb.
313
+ * @param title
314
+ */
315
+ setTitle(title: string) {
316
+ super.setTitle(title)
317
+ document.title = `${title} :: Terrier Hub`
318
+ }
319
+
320
+ private _titleHref?: string
321
+
322
+ /**
323
+ * Adds an href to the title (last) breadcrumb.
324
+ * @param href
325
+ */
326
+ setTitleHref(href: string) {
327
+ this._titleHref = href
328
+ }
329
+
330
+ /**
331
+ * Whether the main content should be constrained to a reasonable width (default) or span the entire screen.
332
+ */
333
+ protected mainContentWidth: ContentWidth = "normal"
334
+
335
+ render(parent: PartTag) {
336
+ parent.div(`.tt-page-part.content-width-${this.mainContentWidth}`, page => {
337
+ page.div('.tt-flex.top-row', topRow => {
338
+ // breadcrumbs
339
+ if (this._breadcrumbs.length || this._title?.length) {
340
+ topRow.h1('.breadcrumbs', h1 => {
341
+ const crumbs = Array.from(this._breadcrumbs)
342
+
343
+ // add a breadcrumb for the page title
344
+ const titleCrumb: Action<TT> = {
345
+ title: this._title,
346
+ icon: this._icon || undefined,
347
+ }
348
+ if (this._titleHref) {
349
+ titleCrumb.href = this._titleHref
350
+ }
351
+ if (this._breadcrumbClasses?.length) {
352
+ titleCrumb.classes = this._breadcrumbClasses
353
+ }
354
+ crumbs.push(titleCrumb)
355
+
356
+ this.app.theme.renderActions(h1, crumbs)
357
+ })
358
+ }
359
+
360
+ // tertiary actions
361
+ if (this.actions.tertiary.length) {
362
+ topRow.div('.tertiary-actions', actions => {
363
+ this.app.theme.renderActions(actions, this.getActions('tertiary'))
364
+ })
365
+ }
366
+ }) // topRow
367
+
368
+ page.div('.lighting')
369
+ page.div('.page-main', main => {
370
+ this.renderContent(main)
371
+ main.div('.page-actions', actions => {
372
+ actions.div('.secondary-actions', container => {
373
+ this.app.theme.renderActions(container, this.getActions('secondary'), {iconColor: 'white', defaultClass: 'secondary'})
374
+ })
375
+ actions.div('.primary-actions', container => {
376
+ this.app.theme.renderActions(container, this.getActions('primary'), {iconColor: 'white', defaultClass: 'primary'})
377
+ })
378
+ })
379
+ })
380
+ })
381
+ }
382
+ }
383
+
384
+
385
+
386
+ /**
387
+ * Default page part if the router can't find the path.
388
+ */
389
+ export class NotFoundRoute<TT extends ThemeType> extends PagePart<NoState, TT> {
390
+ async init() {
391
+ this.setTitle("Page Not Found")
392
+ }
393
+
394
+ renderContent(parent: PartTag) {
395
+ log.warn(`Not found: ${this.context.href}`)
396
+ parent.h1({text: "Not Found"})
397
+ }
398
+
399
+ }
400
+
401
+
402
+ ////////////////////////////////////////////////////////////////////////////////
403
+ // Panel
404
+ ////////////////////////////////////////////////////////////////////////////////
405
+
406
+ /**
407
+ * A part that renders content inside a panel.
408
+ */
409
+ export abstract class PanelPart<T, TT extends ThemeType> extends ContentPart<T, TT> {
410
+
411
+ getLoadingContainer() {
412
+ return this.element?.getElementsByClassName('tt-panel')[0]
413
+ }
414
+
415
+ protected get panelClasses(): string[] {
416
+ return []
417
+ }
418
+
419
+ render(parent: PartTag) {
420
+ parent.div('.tt-panel', panel => {
421
+ panel.class(...this.panelClasses)
422
+ if (this._title?.length || this.actions.tertiary.length) {
423
+ panel.div('.panel-header', header => {
424
+ header.h2(h2 => {
425
+ if (this._icon) {
426
+ this.app.theme.renderIcon(h2, this._icon, 'link')
427
+ }
428
+ h2.div('.title', {text: this._title || 'Call setTitle()'})
429
+ })
430
+ this.theme.renderActions(header, this.getActions('tertiary'))
431
+ })
432
+ }
433
+ panel.div('.panel-content', content => {
434
+ this.renderContent(content)
435
+ })
436
+ Fragments.panelActions(panel, this.getAllActions(), this.theme)
437
+ })
438
+ }
439
+ }
package/theme.ts ADDED
@@ -0,0 +1,89 @@
1
+ import {PartTag} from "tuff-core/parts"
2
+ import {messages} from "tuff-core"
3
+
4
+ export interface ThemeType {
5
+ readonly icons: string
6
+ readonly colors: string
7
+ }
8
+
9
+
10
+ /**
11
+ * A combination of a message key and its associated data.
12
+ */
13
+ export type Packet = {
14
+ key: messages.Key
15
+ data?: Record<string, unknown>
16
+ }
17
+
18
+ /**
19
+ * An action that generates a button or link.
20
+ */
21
+ export type Action<TT extends ThemeType> = {
22
+ title?: string
23
+ tooltip?: string
24
+ icon?: TT['icons']
25
+ href?: string
26
+ classes?: string[]
27
+ click?: Packet
28
+ badge?: string
29
+ }
30
+
31
+ /**
32
+ * Options to pass to `render` that control how the actions are displayed.
33
+ */
34
+ export type RenderActionOptions<TT extends ThemeType> = {
35
+ iconColor?: TT['colors']
36
+ badgeColor?: TT['colors']
37
+ defaultClass?: string
38
+ }
39
+
40
+ export default abstract class Theme<TT extends ThemeType> {
41
+ abstract renderIcon(parent: PartTag, icon: TT['icons'], color?: TT['colors']): void
42
+
43
+ abstract renderCloseIcon(parent: PartTag, color?: TT['colors']): void
44
+
45
+ abstract colorValue(name: TT['colors']): string
46
+
47
+ abstract getLoaderSrc(): string
48
+
49
+ /**
50
+ * Renders one ore more `Action`s into a parent tag.
51
+ * @param parent the HTML element in which to render the action
52
+ * @param actions the action or actions to render
53
+ * @param options additional rendering options
54
+ */
55
+ renderActions(parent: PartTag, actions: Action<TT> | Action<TT>[], options?: RenderActionOptions<TT>) {
56
+ if (!Array.isArray(actions)) {
57
+ actions = [actions]
58
+ }
59
+ for (const action of actions) {
60
+ let iconColor = options?.iconColor
61
+ const attrs = action.tooltip?.length ? {data: {tooltip: action.tooltip}} : {}
62
+ parent.a(attrs, a => {
63
+ const classes = action.classes || []
64
+ if (classes?.length) {
65
+ a.class(...classes)
66
+ } else if (options?.defaultClass?.length) {
67
+ a.class(options.defaultClass)
68
+ }
69
+ if (action.icon?.length) {
70
+ this.renderIcon(a, action.icon, iconColor)
71
+ }
72
+ if (action.title?.length) {
73
+ a.div('.title', {text: action.title})
74
+ }
75
+ if (action.href?.length) {
76
+ a.attrs({href: action.href})
77
+ }
78
+ if (action.click) {
79
+ a.emitClick(action.click.key, action.click.data || {})
80
+ }
81
+ if (action.badge?.length) {
82
+ const badgeColor = options?.badgeColor || 'alert'
83
+ a.div(`.badge.${badgeColor}`, {text: action.badge})
84
+ }
85
+ })
86
+ }
87
+ }
88
+
89
+ }
package/toasts.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { Logger } from "tuff-core/logging"
2
+ import { createElement } from "tuff-core/html"
3
+ import Theme, {ThemeType} from "./theme";
4
+
5
+ const log = new Logger('Toasts')
6
+
7
+ export type ToastOptions<TT extends ThemeType> = {
8
+ color: TT['colors']
9
+ icon?: TT['icons']
10
+ }
11
+
12
+ /**
13
+ * Shows a toast message in a bubble in the upper right corner.
14
+ * @param message the message text
15
+ * @param options
16
+ * @param theme the theme used to render the toast
17
+ */
18
+ function show<TT extends ThemeType>(message: string, options: ToastOptions<TT>, theme: Theme<TT>) {
19
+ log.info(`Show ${options.color }: ${message}`)
20
+
21
+ // ensure the container exists
22
+ let container = document.getElementById('toasts')
23
+ if (!container) {
24
+ container = createElement('div', (div) => {
25
+ div.sel('#toasts.flex.column.padded')
26
+ })
27
+ document.body.appendChild(container)
28
+ }
29
+
30
+ // create the toast element
31
+ const toast = createElement('div', (parent) => {
32
+ parent.class(options.color)
33
+ if (options?.icon) {
34
+ theme.renderIcon(parent, options.icon, 'white')
35
+ }
36
+ parent.span('.text', {text: message})
37
+ })
38
+ container.appendChild(toast)
39
+
40
+ // show the element after a short delay
41
+ setTimeout(
42
+ () => {
43
+ if (toast.isConnected) {
44
+ toast.classList.add('show')
45
+ }
46
+ },
47
+ 100
48
+ )
49
+
50
+ // function to remove the toast if it's clicked or after a timeout
51
+ const remove = () => {
52
+ if (toast.isConnected) {
53
+ toast.classList.remove('show')
54
+ setTimeout(
55
+ () => {toast.remove()},
56
+ 500
57
+ )
58
+ }
59
+ }
60
+
61
+ // remove the toast if it's clicked
62
+ toast.addEventListener('click', _ => {
63
+ remove()
64
+ })
65
+
66
+ // remove the toast after a couple seconds
67
+ setTimeout(
68
+ () => remove(),
69
+ 2000
70
+ )
71
+ }
72
+
73
+ const Toasts = {
74
+ show
75
+ }
76
+
77
+ export default Toasts
package/tooltips.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { Logger } from "tuff-core/logging"
2
+ import { createElement } from "tuff-core/html"
3
+ import Overlays from "./overlays"
4
+
5
+ const log = new Logger('Tooltips')
6
+ Logger.level = 'info'
7
+
8
+ let container: HTMLElement | null = null
9
+
10
+ function ensureContainer(): HTMLElement {
11
+ if (container) {
12
+ return container
13
+ }
14
+ container = createElement('div', div => {
15
+ div.sel('#tooltip')
16
+ })
17
+ document.body.appendChild(container)
18
+ return container
19
+ }
20
+
21
+ function onEnter(target: HTMLElement) {
22
+ log.debug("Mouse enter", target)
23
+ const container = ensureContainer()
24
+ container.classList.add('show')
25
+ container.innerHTML = target.dataset.tooltip ?? ''
26
+ Overlays.anchorElement(container, target)
27
+ }
28
+
29
+ function onLeave(target: HTMLElement) {
30
+ log.debug("Mouse leave", target)
31
+ if (container) {
32
+ container.classList.remove('show')
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Initializes the mouse enter/leave event handlers on the given root element.
38
+ * This should be called during the root element's `update()` method.
39
+ * @param root the root of the DOM over which tooltips listeners should be handled
40
+ */
41
+ function init(root: HTMLElement) {
42
+ log.info("Init", root)
43
+ root.addEventListener("click", evt => {
44
+ if (container && container.classList.contains('show')) {
45
+ log.info("Hiding tooltip", evt)
46
+ container.classList.remove('show')
47
+ }
48
+ }, {capture: true})
49
+ root.addEventListener("mouseenter", evt => {
50
+ if ((evt.target as HTMLElement).dataset?.tooltip?.length) {
51
+ onEnter(evt.target as HTMLElement)
52
+ }
53
+ }, {capture: true})
54
+ root.addEventListener("mouseleave", evt => {
55
+ if ((evt.target as HTMLElement).dataset?.tooltip?.length) {
56
+ onLeave(evt.target as HTMLElement)
57
+ }
58
+ }, {capture: true})
59
+ }
60
+
61
+ const Tooltips = {
62
+ init
63
+ }
64
+
65
+ export default Tooltips