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