dce-reactkit 3.5.5 → 3.5.7

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.
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Container that automatically scrolls when new items are added,
3
+ * lets the user scroll up to see old items, but resumes
4
+ * autoscroll when the user scrolls back to the bottom.
5
+ * Note: takes up full height of parent, so parent should
6
+ * have a determined height for the scroll to work.
7
+ * @author Gabe Abrams
8
+ */
9
+
10
+ // Import React
11
+ import React, { useReducer, useEffect, useRef } from 'react';
12
+
13
+ // Import FontAwesome
14
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
15
+ import {
16
+ faChevronDown,
17
+ } from '@fortawesome/free-solid-svg-icons';
18
+
19
+ // Import shared types
20
+ import Variant from '../types/Variant';
21
+
22
+ // Import shared helper
23
+ import idify from '../helpers/idify';
24
+
25
+ /*------------------------------------------------------------------------*/
26
+ /* -------------------------------- Types ------------------------------- */
27
+ /*------------------------------------------------------------------------*/
28
+
29
+ // Props definition
30
+ type Props = {
31
+ // Name items in the container, "Messages" or "Comments" for example
32
+ // If excluded, a generic name will be used
33
+ itemsName?: string,
34
+ // Items in the scroll container
35
+ items: AutoScrollItem[],
36
+ // Custom variant for the "Jump to Bottom" button
37
+ jumpToBottomButtonVariant?: Variant,
38
+ };
39
+
40
+ // AutoScrollItem definition
41
+ type AutoScrollItem = {
42
+ // Unique item id
43
+ id: string | number,
44
+ // The item to render
45
+ item: React.ReactNode,
46
+ };
47
+
48
+ /*------------------------------------------------------------------------*/
49
+ /* -------------------------------- Style ------------------------------- */
50
+ /*------------------------------------------------------------------------*/
51
+
52
+ const style = `
53
+ .AutoscrollToBottomContainer-outer-container {
54
+ /* Take up all space */
55
+ height: 100%;
56
+ position: relative;
57
+ }
58
+
59
+ .AutoscrollToBottomContainer-scrollable-container {
60
+ /* Take up max 100% height, don't take it up if not needed */
61
+ /* (so column-reverse layout doesn't start at the bottom) */
62
+ max-height: 100%;
63
+ overflow-y: auto;
64
+
65
+ /* Allow children to position */
66
+ position: relative;
67
+
68
+ /* Reverse order of children so scroll starts at bottom */
69
+ /* (but children will have to be rendered in reverse order) */
70
+ display: flex;
71
+ flex-direction: column-reverse;
72
+ }
73
+
74
+ .AutoscrollToBottomContainer-jump-to-bottom-container {
75
+ /* Don't take any space in parent */
76
+ height: 0;
77
+ overflow: visible;
78
+
79
+ /* On top of items */
80
+ z-index: 2;
81
+
82
+ /* Position in center bottom */
83
+ position: absolute;
84
+ bottom: 2rem;
85
+
86
+ /* Center horizontally */
87
+ width: 100%;
88
+ text-align: center;
89
+ }
90
+
91
+ .AutoscrollToBottomContainer-item-container {
92
+ /* Normal Height */
93
+ position: relative;
94
+ z-index: 1;
95
+ }
96
+ `;
97
+
98
+ /*------------------------------------------------------------------------*/
99
+ /* ------------------------------ Constants ----------------------------- */
100
+ /*------------------------------------------------------------------------*/
101
+
102
+ // Scrolled to bottom threshold
103
+ // (how close you have to be to the bottom for it to count as the bottom)
104
+ const SCROLLED_TO_BOTTOM_THRESHOLD_REMS = 2;
105
+
106
+ /*------------------------------------------------------------------------*/
107
+ /* -------------------------- Static Functions -------------------------- */
108
+ /*------------------------------------------------------------------------*/
109
+
110
+ /**
111
+ * Get ids of items
112
+ * @author Gabe Abrams
113
+ * @param items the items in the container
114
+ * @returns ids of items
115
+ */
116
+ const getItemIds = (items: AutoScrollItem[]): (string | number)[] => {
117
+ return Array.from(items).map((child) => {
118
+ return child.id;
119
+ });
120
+ };
121
+
122
+ /*------------------------------------------------------------------------*/
123
+ /* -------------------------------- State ------------------------------- */
124
+ /*------------------------------------------------------------------------*/
125
+
126
+ /* -------- State Definition -------- */
127
+
128
+ type State = {
129
+ // If true, the user was scrolled to the bottom after last update
130
+ wasScrolledToBottom: boolean,
131
+ // If true, the "jump to bottom" button is visible
132
+ jumpToBottomButtonVisible: boolean,
133
+ };
134
+
135
+ /* ------------- Actions ------------ */
136
+
137
+ // Types of actions
138
+ enum ActionType {
139
+ // Identify that the user is now scrolled to the bottom
140
+ NowScrolledToBottom = 'NowScrolledToBottom',
141
+ // Identify that the user scrolled away from the bottom
142
+ NowScrolledAwayFromBottom = 'NowScrolledAwayFromBottom',
143
+ // Identify that new content appeared at the bottom but is not visible
144
+ NewContentAtBottom = 'NewContentAtBottom',
145
+ }
146
+
147
+ // Action definitions
148
+ type Action = (
149
+ | {
150
+ // Action type
151
+ type: (
152
+ | ActionType.NowScrolledToBottom
153
+ | ActionType.NowScrolledAwayFromBottom
154
+ | ActionType.NewContentAtBottom
155
+ ),
156
+ }
157
+ );
158
+
159
+ /**
160
+ * Reducer that executes actions
161
+ * @author Gabe Abrams
162
+ * @param state current state
163
+ * @param action action to execute
164
+ */
165
+ const reducer = (state: State, action: Action): State => {
166
+ switch (action.type) {
167
+ case ActionType.NowScrolledToBottom: {
168
+ return {
169
+ ...state,
170
+ // Store the event
171
+ wasScrolledToBottom: true,
172
+ // Hide the "jump to bottom" button
173
+ jumpToBottomButtonVisible: false,
174
+ };
175
+ }
176
+ case ActionType.NowScrolledAwayFromBottom: {
177
+ return {
178
+ ...state,
179
+ // Store the event
180
+ wasScrolledToBottom: false,
181
+ };
182
+ }
183
+ case ActionType.NewContentAtBottom: {
184
+ return {
185
+ ...state,
186
+ // Store the event
187
+ wasScrolledToBottom: false,
188
+ // Show the "jump to bottom" button
189
+ jumpToBottomButtonVisible: true,
190
+ };
191
+ }
192
+ default: {
193
+ return state;
194
+ }
195
+ }
196
+ };
197
+
198
+ /*------------------------------------------------------------------------*/
199
+ /* ------------------------------ Component ----------------------------- */
200
+ /*------------------------------------------------------------------------*/
201
+
202
+ const AutoscrollToBottomContainer: React.FC<Props> = (props) => {
203
+ /*------------------------------------------------------------------------*/
204
+ /* -------------------------------- Setup ------------------------------- */
205
+ /*------------------------------------------------------------------------*/
206
+
207
+ /* -------------- Props ------------- */
208
+
209
+ // Destructure all props
210
+ const {
211
+ itemsName,
212
+ items,
213
+ jumpToBottomButtonVariant = Variant.Danger,
214
+ } = props;
215
+
216
+ /* -------------- State ------------- */
217
+
218
+ // Initial state
219
+ const initialState: State = {
220
+ wasScrolledToBottom: true,
221
+ jumpToBottomButtonVisible: false,
222
+ };
223
+
224
+ // Initialize state
225
+ const [state, dispatch] = useReducer(reducer, initialState);
226
+
227
+ // Destructure common state
228
+ const {
229
+ wasScrolledToBottom,
230
+ jumpToBottomButtonVisible,
231
+ } = state;
232
+
233
+ /* -------------- Refs -------------- */
234
+
235
+ // Initialize refs
236
+ const lastItemIds = useRef<(string | number)[]>([]);
237
+ const container = useRef<HTMLDivElement | null>(null);
238
+
239
+ /*------------------------------------------------------------------------*/
240
+ /* ------------------------- Component Functions ------------------------ */
241
+ /*------------------------------------------------------------------------*/
242
+
243
+ /*----------------------------------------*/
244
+ /* --------------- Scroll --------------- */
245
+ /*----------------------------------------*/
246
+
247
+ /**
248
+ * Check if the user is scrolled to the bottom
249
+ * @author Gabe Abrams
250
+ * @returns true if scrolled to the bottom
251
+ */
252
+ const isScrolledToBottom = (): boolean => {
253
+ // Skip if no container
254
+ if (!container.current) {
255
+ return true;
256
+ }
257
+
258
+ // Get info about container
259
+ const {
260
+ scrollTop,
261
+ } = container.current;
262
+
263
+ // Distance to bottom
264
+ const rootFontSizePx = Number.parseInt(
265
+ getComputedStyle(document.documentElement).fontSize,
266
+ 10,
267
+ );
268
+ const distanceToBottomRems = Math.abs(scrollTop / rootFontSizePx);
269
+
270
+ // Figure out if we're scrolled to the bottom
271
+ return (distanceToBottomRems < SCROLLED_TO_BOTTOM_THRESHOLD_REMS);
272
+ };
273
+
274
+ /**
275
+ * Scroll the user to the bottom of the container
276
+ * @author Gabe Abrams
277
+ */
278
+ const scrollToBottom = () => {
279
+ // Skip if no container
280
+ if (!container.current) {
281
+ return;
282
+ }
283
+
284
+ // Update state
285
+ dispatch({
286
+ type: ActionType.NowScrolledToBottom,
287
+ });
288
+
289
+ // Scroll to bottom
290
+ container.current.scrollTop = 0;
291
+ };
292
+
293
+ /*----------------------------------------*/
294
+ /* -------------- Handlers -------------- */
295
+ /*----------------------------------------*/
296
+
297
+ /**
298
+ * Handle scroll events on the container
299
+ * @author Gabe Abrams
300
+ */
301
+ const handleScroll = () => {
302
+ // Skip if no container
303
+ if (!container.current) {
304
+ return;
305
+ }
306
+
307
+ // Check if now scrolled to bottom
308
+ const nowScrolledToBottom = isScrolledToBottom();
309
+
310
+ // Remember if now no longer scrolled to bottom
311
+ if (nowScrolledToBottom) {
312
+ dispatch({
313
+ type: ActionType.NowScrolledToBottom,
314
+ });
315
+ } else {
316
+ dispatch({
317
+ type: ActionType.NowScrolledAwayFromBottom,
318
+ });
319
+ }
320
+ };
321
+
322
+ /*------------------------------------------------------------------------*/
323
+ /* ------------------------- Lifecycle Functions ------------------------ */
324
+ /*------------------------------------------------------------------------*/
325
+
326
+ /**
327
+ * Mount
328
+ * @author Gabe Abrams
329
+ */
330
+ useEffect(
331
+ () => {
332
+ // Scroll to the bottom
333
+ scrollToBottom();
334
+ },
335
+ [],
336
+ );
337
+
338
+ /**
339
+ * Update (also called on mount)
340
+ * @author Gabe Abrams
341
+ */
342
+ useEffect(
343
+ () => {
344
+ // Check if new content appeared
345
+ const currentItemIds = getItemIds(items);
346
+
347
+ // Check if new content appeared at bottom
348
+ const newContentAtBottom = (
349
+ currentItemIds.length > 0
350
+ && (
351
+ currentItemIds[currentItemIds.length - 1]
352
+ !== lastItemIds.current[lastItemIds.current.length - 1]
353
+ )
354
+ );
355
+
356
+ // Do nothing if no new content
357
+ if (!newContentAtBottom) {
358
+ return;
359
+ }
360
+
361
+ // Check if used to be scrolled to the bottom
362
+ if (wasScrolledToBottom) {
363
+ // Was scrolled to the bottom! Autoscroll.
364
+ scrollToBottom();
365
+ } else {
366
+ // Not scrolled to bottom. Show "jump to bottom" button.
367
+ dispatch({
368
+ type: ActionType.NewContentAtBottom,
369
+ });
370
+ }
371
+
372
+ // Update last item ids
373
+ lastItemIds.current = currentItemIds;
374
+ },
375
+ [items],
376
+ );
377
+
378
+ /*------------------------------------------------------------------------*/
379
+ /* ------------------------------- Render ------------------------------- */
380
+ /*------------------------------------------------------------------------*/
381
+
382
+ /*----------------------------------------*/
383
+ /* --------------- Main UI -------------- */
384
+ /*----------------------------------------*/
385
+
386
+ // Jump to Bottom button
387
+ let jumpToBottomButton: React.ReactNode;
388
+ if (jumpToBottomButtonVisible) {
389
+ jumpToBottomButton = (
390
+ <div className={`AutoscrollToBottomContainer-jump-to-bottom-container AutoscrollToBottomContainer-for-${idify(itemsName ?? 'items')}`}>
391
+ <button
392
+ type="button"
393
+ className={`AutoscrollToBottomContainer-jump-to-bottom-button AutoscrollToBottomContainer-jump-to-bottom-button-for-${idify(itemsName ?? 'items')} btn btn-sm btn-${jumpToBottomButtonVariant} pt-0 pb-0`}
394
+ onClick={scrollToBottom}
395
+ aria-label="scroll back to bottom and show new content"
396
+ >
397
+ New
398
+ {' '}
399
+ {itemsName ?? 'Content'}
400
+ <FontAwesomeIcon
401
+ icon={faChevronDown}
402
+ className="ms-1"
403
+ />
404
+ </button>
405
+ </div>
406
+ );
407
+ }
408
+
409
+ // Main UI
410
+ return (
411
+ <div className="AutoscrollToBottomContainer-outer-container">
412
+ {/* Style */}
413
+ <style>
414
+ {style}
415
+ </style>
416
+
417
+ {/* Jump to bottom button */}
418
+ {jumpToBottomButton}
419
+
420
+ {/* Scrollable Item Container */}
421
+ <div
422
+ className="AutoscrollToBottomContainer-scrollable-container"
423
+ onScroll={() => {
424
+ handleScroll();
425
+ }}
426
+ ref={container}
427
+ >
428
+ {/* Items */}
429
+ {
430
+ items
431
+ // Render each item with a key
432
+ .map((item) => {
433
+ return (
434
+ <div
435
+ className="AutoscrollToBottomContainer-item-container"
436
+ key={item.id}
437
+ >
438
+ {item.item}
439
+ </div>
440
+ );
441
+ })
442
+ // Reverse order because flex column is reverse
443
+ .reverse()
444
+ }
445
+ </div>
446
+ </div>
447
+ );
448
+ };
449
+
450
+ /*------------------------------------------------------------------------*/
451
+ /* ------------------------------- Wrap Up ------------------------------ */
452
+ /*------------------------------------------------------------------------*/
453
+
454
+ // Export component
455
+ export default AutoscrollToBottomContainer;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Convert a string to hyphenated lowercase format with no space or
3
+ * non-alphanumeric characters
4
+ * @author Gabe Abrams
5
+ * @param str the string to convert
6
+ * @returns the idified string
7
+ */
8
+ const idify = (str: string): string => {
9
+ return (
10
+ str
11
+ // Trim whitespace
12
+ .trim()
13
+ // Convert to lowercase
14
+ .toLowerCase()
15
+ // Replace non-alphanumeric characters with hyphens
16
+ .replace(/[^a-z0-9]+/g, '-')
17
+ // Change multiple hyphens in a row for a single hyphen
18
+ .replace(/-+/g, '-')
19
+ // Remove hyphens at the beginning and end of the string
20
+ .replace(/^-+|-+$/g, '')
21
+ );
22
+ };
23
+
24
+ export default idify;
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import CSVDownloadButton from './components/CSVDownloadButton';
25
25
  import DBEntryManagerPanel from './components/DBEntryManagerPanel';
26
26
  import Tooltip from './components/Tooltip';
27
27
  import ToggleSwitch from './components/ToggleSwitch';
28
+ import AutoscrollToBottomContainer from './components/AutoscrollToBottomContainer';
28
29
 
29
30
  // Import errors
30
31
  import ErrorWithCode from './errors/ErrorWithCode';
@@ -77,6 +78,7 @@ import genCommaList from './helpers/genCommaList';
77
78
  import validateEmail from './helpers/validators/validateEmail';
78
79
  import validatePhoneNumber from './helpers/validators/validatePhoneNumber';
79
80
  import validateString from './helpers/validators/validateString';
81
+ import idify from './helpers/idify';
80
82
 
81
83
  // Import types
82
84
  import ModalButtonType from './types/ModalButtonType';
@@ -124,6 +126,7 @@ export {
124
126
  DBEntryManagerPanel,
125
127
  Tooltip,
126
128
  ToggleSwitch,
129
+ AutoscrollToBottomContainer,
127
130
  // Global functions
128
131
  alert,
129
132
  confirm,
@@ -167,6 +170,7 @@ export {
167
170
  validatePhoneNumber,
168
171
  validateString,
169
172
  getLocalTimeInfo,
173
+ idify,
170
174
  // Client helpers
171
175
  initClient,
172
176
  visitServerEndpoint,