terrier-engine 4.9.0 → 4.10.0
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/package.json +1 -1
- package/terrier/list-viewer.ts +218 -75
- package/terrier/parts/page-part.ts +11 -1
package/package.json
CHANGED
package/terrier/list-viewer.ts
CHANGED
|
@@ -1,23 +1,180 @@
|
|
|
1
1
|
import TerrierPart from "./parts/terrier-part"
|
|
2
|
-
import {PartTag} from "tuff-core/parts"
|
|
2
|
+
import {Part, PartConstructor, PartTag, StatelessPart} from "tuff-core/parts"
|
|
3
3
|
import Messages from "tuff-core/messages"
|
|
4
4
|
import {Logger} from "tuff-core/logging"
|
|
5
|
-
import
|
|
5
|
+
import {DivTag} from "tuff-core/html";
|
|
6
|
+
import {PageBreakpoints} from "./parts/page-part";
|
|
6
7
|
|
|
7
8
|
const log = new Logger('List Viewer')
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Optional values to return from rendering a list item that control
|
|
12
|
+
* its rendering behavior.
|
|
13
|
+
*/
|
|
14
|
+
export type ListItemRenderOptions = {
|
|
15
|
+
style?: 'panel' | 'header'
|
|
16
|
+
clickable?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* All list items should have an `id` value so that we can distinguish them.
|
|
21
|
+
*/
|
|
22
|
+
export type ListItem = {id: string}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One of these gets created for each item in the list.
|
|
26
|
+
*/
|
|
27
|
+
class ListItemPart<T extends ListItem> extends Part<T> {
|
|
28
|
+
|
|
29
|
+
viewer!: ListViewerPart<T>
|
|
30
|
+
|
|
31
|
+
render(parent: PartTag) {
|
|
32
|
+
const isCurrent = this.viewer.detailsContext?.id == this.state.id
|
|
33
|
+
|
|
34
|
+
parent.div('.tt-list-viewer-item', itemView => {
|
|
35
|
+
const opts = this.viewer.renderListItem(itemView, this.state)
|
|
36
|
+
const style = opts?.style || 'panel'
|
|
37
|
+
itemView.class(style)
|
|
38
|
+
if (opts?.clickable) {
|
|
39
|
+
itemView.class('clickable')
|
|
40
|
+
itemView.emitClick(this.viewer.itemClickedKey, {id: this.state.id})
|
|
41
|
+
}
|
|
42
|
+
if (isCurrent) {
|
|
43
|
+
itemView.class('current')
|
|
44
|
+
}
|
|
45
|
+
}).id(`item-${this.state.id}`)
|
|
46
|
+
|
|
47
|
+
// render the details if this is the current item and it's supposed to be rendered inline
|
|
48
|
+
if (isCurrent && this.viewer.detailsLocation == 'inline') {
|
|
49
|
+
if (this.viewer.currentDetailsPart) {
|
|
50
|
+
parent.part(this.viewer.currentDetailsPart)
|
|
51
|
+
}
|
|
52
|
+
else if (this.viewer.currentDetailsDiv) {
|
|
53
|
+
parent.div().append(this.viewer.currentDetailsDiv)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Allows ListViewerPart subclasses to either directly render content for
|
|
62
|
+
* the list details or make a part to do it.
|
|
63
|
+
*/
|
|
64
|
+
export class ListViewerDetailsContext<T extends ListItem> {
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The item id for which this context is representing the details.
|
|
68
|
+
*/
|
|
69
|
+
get id(): string {
|
|
70
|
+
return this.item.id
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The part created by `makePart()` to render the details.
|
|
75
|
+
*/
|
|
76
|
+
part?: StatelessPart
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The part representing this item in the list.
|
|
80
|
+
*/
|
|
81
|
+
itemPart?: ListItemPart<T>
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The tag rendered by `renderDirect()` to represent the details until
|
|
85
|
+
* the details part renders (if that happens at all).
|
|
86
|
+
*/
|
|
87
|
+
directDiv?: DivTag
|
|
88
|
+
|
|
89
|
+
constructor(readonly viewer: ListViewerPart<T>, readonly item: T) {
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Make a part to render the details for the list item.
|
|
94
|
+
* @param partType
|
|
95
|
+
* @param state
|
|
96
|
+
*/
|
|
97
|
+
makePart<PartType extends Part<StateType>, StateType>(partType: PartConstructor<PartType, StateType>, state: StateType) {
|
|
98
|
+
this.part = this.viewer.makePart(partType, state)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Render the content for the details directly.
|
|
103
|
+
* This will be replaced by the part made by `makePart()`.
|
|
104
|
+
* @param fn the render function for the content
|
|
105
|
+
*/
|
|
106
|
+
renderDirect(fn: (parent: DivTag) => any) {
|
|
107
|
+
this.directDiv = new DivTag("div")
|
|
108
|
+
fn(this.directDiv)
|
|
109
|
+
return this.directDiv
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ensure that the rendered part is disposed and the related item part is marked dirty.
|
|
114
|
+
*/
|
|
115
|
+
clear() {
|
|
116
|
+
if (this.part) {
|
|
117
|
+
this.viewer.removeChild(this.part)
|
|
118
|
+
this.part = undefined
|
|
119
|
+
}
|
|
120
|
+
if (this.itemPart) {
|
|
121
|
+
this.itemPart.dirty()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* This part sits permanently on the side and will render the details if
|
|
128
|
+
* the viewer's detailsLocation = 'side'.
|
|
129
|
+
*/
|
|
130
|
+
class SideContainerPart extends Part<{viewer: ListViewerPart<any>}> {
|
|
131
|
+
|
|
132
|
+
viewer!: ListViewerPart<any>
|
|
133
|
+
|
|
134
|
+
async init() {
|
|
135
|
+
this.viewer = this.state.viewer
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get parentClasses(): Array<string> {
|
|
139
|
+
return ['tt-list-viewer-side-details']
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
render(parent: PartTag) {
|
|
143
|
+
if (this.viewer.detailsLocation == 'side') {
|
|
144
|
+
if (this.viewer.currentDetailsPart) {
|
|
145
|
+
parent.part(this.viewer.currentDetailsPart)
|
|
146
|
+
} else if (this.viewer.currentDetailsDiv) {
|
|
147
|
+
parent.div().append(this.viewer.currentDetailsDiv)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
}
|
|
153
|
+
|
|
10
154
|
|
|
11
155
|
/**
|
|
12
156
|
* Part for viewing a list of items and the details associated with them.
|
|
13
157
|
* Each item must have an `id` so that they can be distinguished.
|
|
14
158
|
*/
|
|
15
|
-
export abstract class ListViewerPart<T extends
|
|
159
|
+
export abstract class ListViewerPart<T extends ListItem> extends TerrierPart<any> {
|
|
160
|
+
|
|
161
|
+
sideContainerPart!: SideContainerPart
|
|
162
|
+
|
|
163
|
+
detailsContext?: ListViewerDetailsContext<T>
|
|
164
|
+
detailsLocation: 'inline' | 'side' = 'inline'
|
|
165
|
+
|
|
166
|
+
get currentDetailsPart(): StatelessPart | undefined {
|
|
167
|
+
return this.detailsContext?.part
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get currentDetailsDiv(): DivTag | undefined {
|
|
171
|
+
return this.detailsContext?.directDiv
|
|
172
|
+
}
|
|
16
173
|
|
|
17
174
|
items: T[] = []
|
|
18
|
-
|
|
175
|
+
itemPartMap: Record<string, ListItemPart<T>> = {}
|
|
19
176
|
|
|
20
|
-
itemClickedKey = Messages.typedKey<
|
|
177
|
+
itemClickedKey = Messages.typedKey<ListItem>()
|
|
21
178
|
|
|
22
179
|
/**
|
|
23
180
|
* A message with this key gets emitted whenever the details are shown.
|
|
@@ -27,6 +184,9 @@ export abstract class ListViewerPart<T extends {id: string}> extends TerrierPart
|
|
|
27
184
|
async init() {
|
|
28
185
|
await super.init()
|
|
29
186
|
|
|
187
|
+
this.computeDetailsLocation()
|
|
188
|
+
this.sideContainerPart = this.makePart(SideContainerPart, {viewer: this})
|
|
189
|
+
|
|
30
190
|
await this.reload()
|
|
31
191
|
|
|
32
192
|
this.onClick(this.itemClickedKey, m => {
|
|
@@ -38,12 +198,22 @@ export abstract class ListViewerPart<T extends {id: string}> extends TerrierPart
|
|
|
38
198
|
|
|
39
199
|
/// Fetching
|
|
40
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Subclasses must implement this to provide a list of items to render.
|
|
203
|
+
*/
|
|
41
204
|
abstract fetchItems(): Promise<T[]>
|
|
42
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Fetches the items with `fetchItems()` and re-renders the list.
|
|
208
|
+
*/
|
|
43
209
|
async reload() {
|
|
44
210
|
this.items = await this.fetchItems()
|
|
45
|
-
this.items.
|
|
46
|
-
|
|
211
|
+
this.assignCollection('items', ListItemPart, this.items)
|
|
212
|
+
this.getCollectionParts('items').forEach(itemPart => {
|
|
213
|
+
// HACK
|
|
214
|
+
(itemPart as ListItemPart<T>).viewer = this
|
|
215
|
+
log.info(`Adding item part ${itemPart.state.id}`, itemPart)
|
|
216
|
+
this.itemPartMap[itemPart.state.id] = (itemPart as ListItemPart<T>)
|
|
47
217
|
})
|
|
48
218
|
this.dirty()
|
|
49
219
|
}
|
|
@@ -57,90 +227,63 @@ export abstract class ListViewerPart<T extends {id: string}> extends TerrierPart
|
|
|
57
227
|
|
|
58
228
|
render(parent: PartTag): any {
|
|
59
229
|
parent.div('.tt-list-viewer-list', list => {
|
|
60
|
-
|
|
61
|
-
list.a('.tt-list-viewer-item', {id: `item-${item.id}`}, itemView => {
|
|
62
|
-
this.renderListItem(itemView, item)
|
|
63
|
-
}).emitClick(this.itemClickedKey, {id: item.id})
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
parent.div('.tt-list-viewer-details-container', detailsView => {
|
|
67
|
-
detailsView.div(detailsSelector)
|
|
230
|
+
this.renderCollection(list, 'items')
|
|
68
231
|
})
|
|
232
|
+
parent.part(this.sideContainerPart)
|
|
69
233
|
}
|
|
70
|
-
abstract renderListItem(parent: PartTag, item: T): any
|
|
71
|
-
|
|
72
|
-
abstract renderItemDetail(parent: PartTag, item: T): any
|
|
73
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Subclasses must override this to render the content of an item.
|
|
237
|
+
* @param parent
|
|
238
|
+
* @param item
|
|
239
|
+
*/
|
|
240
|
+
abstract renderListItem(parent: PartTag, item: T): ListItemRenderOptions | void
|
|
74
241
|
|
|
75
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Subclasses must implement this to render an item details or provide a part to do so.
|
|
244
|
+
* @param context
|
|
245
|
+
*/
|
|
246
|
+
abstract renderDetails(context: ListViewerDetailsContext<T>): any
|
|
76
247
|
|
|
77
|
-
private setCurrent(id: string) {
|
|
78
|
-
// clear any existing current item
|
|
79
|
-
const existingCurrents = this.element!.querySelectorAll('.tt-list-viewer-list a.current')
|
|
80
|
-
existingCurrents.forEach((elem) => {
|
|
81
|
-
elem.classList.remove('current')
|
|
82
|
-
})
|
|
83
248
|
|
|
84
|
-
|
|
85
|
-
const itemView = this.element!.querySelector(`#item-${id}`)
|
|
86
|
-
if (itemView) {
|
|
87
|
-
itemView.classList.add('current')
|
|
88
|
-
}
|
|
89
|
-
}
|
|
249
|
+
// Details
|
|
90
250
|
|
|
91
251
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
252
|
+
* Determine whether the details should be shown inline with the list or
|
|
253
|
+
* off to the side, based on screen size.
|
|
94
254
|
*/
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (!item) {
|
|
99
|
-
throw `No item ${id}`
|
|
100
|
-
}
|
|
101
|
-
const container = this.element!.querySelector(detailsSelector)
|
|
102
|
-
if (container) {
|
|
103
|
-
// render the details
|
|
104
|
-
const detailsView = Html.createElement('div', div => {
|
|
105
|
-
this.renderItemDetail(div, item)
|
|
106
|
-
})
|
|
107
|
-
container.innerHTML = detailsView.innerHTML
|
|
108
|
-
this.arrangeDetails(id, container as HTMLElement)
|
|
109
|
-
|
|
110
|
-
// let the world know
|
|
111
|
-
this.emitMessage(this.detailsShownKey, {id})
|
|
255
|
+
computeDetailsLocation() {
|
|
256
|
+
if (window.innerWidth > PageBreakpoints.phone) {
|
|
257
|
+
this.detailsLocation = 'side'
|
|
112
258
|
}
|
|
113
259
|
else {
|
|
114
|
-
|
|
260
|
+
this.detailsLocation = 'inline'
|
|
115
261
|
}
|
|
116
262
|
}
|
|
117
263
|
|
|
118
264
|
/**
|
|
119
|
-
*
|
|
120
|
-
* @param id
|
|
121
|
-
* @param detailsView
|
|
265
|
+
* Show the details view for the item with the given id
|
|
266
|
+
* @param id
|
|
122
267
|
*/
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// move the details to right after the list item
|
|
131
|
-
itemView.after(detailsView)
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
// move the details back to the container
|
|
135
|
-
const detailsContainer = this.element!.querySelector(`.tt-list-viewer-details-container`)
|
|
136
|
-
if (detailsContainer) {
|
|
137
|
-
detailsContainer.append(detailsView)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
log.warn(`No item view for ${id}`)
|
|
268
|
+
showDetails(id: string) {
|
|
269
|
+
this.detailsContext?.clear()
|
|
270
|
+
|
|
271
|
+
const itemPart = this.itemPartMap[id]
|
|
272
|
+
if (!itemPart) {
|
|
273
|
+
log.info(`${Object.keys(this.itemPartMap).length} item parts: `, this.itemPartMap)
|
|
274
|
+
throw `No item part ${id}`
|
|
143
275
|
}
|
|
276
|
+
|
|
277
|
+
this.detailsContext = new ListViewerDetailsContext(this, itemPart.state)
|
|
278
|
+
this.renderDetails(this.detailsContext)
|
|
279
|
+
this.sideContainerPart.dirty()
|
|
280
|
+
this.detailsContext.itemPart = itemPart
|
|
281
|
+
itemPart.dirty()
|
|
282
|
+
|
|
283
|
+
// let the world know
|
|
284
|
+
this.emitMessage(this.detailsShownKey, {id})
|
|
285
|
+
|
|
144
286
|
}
|
|
145
287
|
|
|
288
|
+
|
|
146
289
|
}
|
|
@@ -10,7 +10,17 @@ const log = new Logger("Terrier PagePart")
|
|
|
10
10
|
/**
|
|
11
11
|
* Whether some content should be constrained to a reasonable width or span the entire screen.
|
|
12
12
|
*/
|
|
13
|
-
export type ContentWidth = "normal" | "wide"
|
|
13
|
+
export type ContentWidth = "normal" | "wide" | "fill"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The width breakpoints for various device classes used by the styles.
|
|
17
|
+
*/
|
|
18
|
+
export const PageBreakpoints = {
|
|
19
|
+
phone: 550,
|
|
20
|
+
mobile: 850,
|
|
21
|
+
tablet: 1050,
|
|
22
|
+
fixed: 320
|
|
23
|
+
}
|
|
14
24
|
|
|
15
25
|
/// Toolbar fields
|
|
16
26
|
|