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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  "*"
6
6
  ],
7
- "version": "4.9.0",
7
+ "version": "4.10.0",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/Terrier-Tech/terrier-engine"
@@ -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 Html from "tuff-core/html"
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
- const detailsSelector = '.tt-list-viewer-details'
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 {id: string}> extends TerrierPart<any> {
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
- itemMap: Record<string, T> = {}
175
+ itemPartMap: Record<string, ListItemPart<T>> = {}
19
176
 
20
- itemClickedKey = Messages.typedKey<{id: string}>()
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.forEach((item) => {
46
- this.itemMap[item.id] = item
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
- for (const item of this.items) {
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
- // Details
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
- // add .current to the new item
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
- * Show the details view for the item with the given id
93
- * @param id
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
- showDetails(id: string) {
96
- this.setCurrent(id)
97
- const item = this.itemMap[id]
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
- log.warn(`Tried to show item ${id} but there was no ${detailsSelector}`)
260
+ this.detailsLocation = 'inline'
115
261
  }
116
262
  }
117
263
 
118
264
  /**
119
- * If necessary, move the details next to the item
120
- * @param id the item id
121
- * @param detailsView
265
+ * Show the details view for the item with the given id
266
+ * @param id
122
267
  */
123
- arrangeDetails(id: string, detailsView: HTMLElement) {
124
- const itemView = this.element!.querySelector(`#item-${id}`)
125
- if (itemView) {
126
- // const listView = itemIVew.parentElement
127
- log.info(`Item is ${itemView.clientWidth} wide and the window is ${window.innerWidth} wide`)
128
- // crude but effective way to determine if the list is collapsed due to the media breakpoint
129
- if (itemView.clientWidth > window.innerWidth * 0.8) {
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