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 +81 -0
- package/dropdowns.ts +96 -0
- package/fragments.ts +396 -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/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
|