@jetbrains/ring-ui 8.0.0-beta.3 → 8.0.0-beta.4

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.
Files changed (60) hide show
  1. package/components/checkbox/checkbox.d.ts +1 -1
  2. package/components/collapsible-group/collapsible-group.css +138 -0
  3. package/components/collapsible-group/collapsible-group.d.ts +20 -0
  4. package/components/collapsible-group/collapsible-group.js +73 -0
  5. package/components/editable-heading/editable-heading.js +14 -14
  6. package/components/expand/collapsible-group.css +23 -89
  7. package/components/expand/collapsible-group.d.ts +8 -20
  8. package/components/expand/collapsible-group.js +14 -73
  9. package/components/global/compose-refs.d.ts +2 -1
  10. package/components/global/compose-refs.js +24 -8
  11. package/components/global/focus-with-temporary-tabindex.d.ts +11 -0
  12. package/components/global/focus-with-temporary-tabindex.js +21 -0
  13. package/components/global/intersection-observer-context.d.ts +29 -9
  14. package/components/global/intersection-observer-context.js +43 -24
  15. package/components/global/is-within-interactive-element.d.ts +6 -0
  16. package/components/global/is-within-interactive-element.js +26 -0
  17. package/components/global/is-within-navigable-element.d.ts +6 -0
  18. package/components/global/is-within-navigable-element.js +27 -0
  19. package/components/global/parse-css-duration.d.ts +5 -0
  20. package/components/global/parse-css-duration.js +13 -0
  21. package/components/global/schedule-with-cleanup.d.ts +12 -0
  22. package/components/global/schedule-with-cleanup.js +34 -0
  23. package/components/global/table-selection.js +1 -1
  24. package/components/input/input.d.ts +1 -1
  25. package/components/legacy-table/row.d.ts +1 -1
  26. package/components/popup/popup.d.ts +2 -0
  27. package/components/popup/popup.js +2 -2
  28. package/components/select/select-popup.d.ts +1 -1
  29. package/components/select/select.d.ts +1 -1
  30. package/components/table/default-item-renderer.d.ts +23 -10
  31. package/components/table/default-item-renderer.js +40 -19
  32. package/components/table/internal/column-animation.d.ts +16 -0
  33. package/components/table/internal/column-animation.js +45 -0
  34. package/components/table/internal/table-header.d.ts +4 -0
  35. package/components/table/internal/table-header.js +357 -0
  36. package/components/table/internal/virtual-items.d.ts +34 -0
  37. package/components/table/{table-virtualize.js → internal/virtual-items.js} +68 -33
  38. package/components/table/item-virtualization.d.ts +35 -0
  39. package/components/table/item-virtualization.js +28 -0
  40. package/components/table/table-const.d.ts +40 -6
  41. package/components/table/table-const.js +14 -5
  42. package/components/table/table-primitives.d.ts +9 -16
  43. package/components/table/table-primitives.js +6 -60
  44. package/components/table/table-props.d.ts +280 -0
  45. package/components/table/table-props.js +1 -0
  46. package/components/table/table.css +171 -66
  47. package/components/table/table.d.ts +205 -209
  48. package/components/table/table.js +298 -2
  49. package/components/util-stories.d.ts +26 -0
  50. package/components/util-stories.js +61 -0
  51. package/package.json +29 -30
  52. package/components/date-picker/use-intersection-observer.d.ts +0 -6
  53. package/components/date-picker/use-intersection-observer.js +0 -48
  54. package/components/global/composeRefs.d.ts +0 -6
  55. package/components/global/composeRefs.js +0 -7
  56. package/components/table/table-component.d.ts +0 -80
  57. package/components/table/table-component.js +0 -130
  58. package/components/table/table-row-focus.d.ts +0 -4
  59. package/components/table/table-row-focus.js +0 -42
  60. package/components/table/table-virtualize.d.ts +0 -32
@@ -0,0 +1,357 @@
1
+ /* eslint-disable no-nested-ternary, max-lines */
2
+ import { use, useCallback, useRef, useState } from 'react';
3
+ import classNames from 'classnames';
4
+ import arrowDownIcon from '@jetbrains/icons/arrow-12px-down';
5
+ import arrowUpIcon from '@jetbrains/icons/arrow-12px-up';
6
+ import dragIcon from '@jetbrains/icons/drag-12px';
7
+ import settingsIcon from '@jetbrains/icons/settings-12px';
8
+ import trashIcon from '@jetbrains/icons/trash-12px';
9
+ import unsortedIcon from '@jetbrains/icons/unsorted-12px';
10
+ import { ColumnAnimationContext, TablePropsContext } from '../table-const';
11
+ import Icon from '../../icon';
12
+ import { useComposedRef } from '../../global/compose-refs';
13
+ import { isWithinInteractiveElement } from '../../global/is-within-interactive-element';
14
+ import { parseCssDuration } from '../../global/parse-css-duration';
15
+ import styles from '../table.css';
16
+ export function TableHeader({ expectColumnReorder }) {
17
+ const { columns, noHeader, stickyHeader, columnEditing, onColumnEditingRequest, theadClassName, theadTrClassName } = use(TablePropsContext);
18
+ const [localColumnEditing, setLocalColumnEditing] = useState(false);
19
+ const effectiveColumnEditing = columnEditing ?? localColumnEditing;
20
+ const toggleColumnEditing = useCallback((source) => {
21
+ let newColumnEditing;
22
+ if (columnEditing == null) {
23
+ newColumnEditing = !localColumnEditing;
24
+ setLocalColumnEditing(newColumnEditing);
25
+ }
26
+ else {
27
+ newColumnEditing = !columnEditing;
28
+ }
29
+ onColumnEditingRequest?.(newColumnEditing, source);
30
+ }, [columnEditing, localColumnEditing, onColumnEditingRequest]);
31
+ const handleTheadClick = useCallback((e) => {
32
+ if (window.matchMedia('(hover: none)').matches && !isWithinInteractiveElement(e.target)) {
33
+ toggleColumnEditing('header');
34
+ }
35
+ }, [toggleColumnEditing]);
36
+ const handleEditColumnsButtonClick = useCallback(() => {
37
+ toggleColumnEditing('edit-button');
38
+ }, [toggleColumnEditing]);
39
+ if (noHeader)
40
+ return null;
41
+ return (
42
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events
43
+ <thead className={classNames(theadClassName, effectiveColumnEditing && styles.theadColumnEditing, stickyHeader && styles.stickyHeader)} onClick={handleTheadClick}>
44
+ <tr className={theadTrClassName}>
45
+ {columns.map((column, columnIndex) => (<TableHeaderCell key={column.key} columnIndex={columnIndex} columnEditing={effectiveColumnEditing} handleEditColumnsButtonClick={handleEditColumnsButtonClick} expectColumnReorder={expectColumnReorder}/>))}
46
+ </tr>
47
+ </thead>);
48
+ }
49
+ function TableHeaderCell({ columnIndex, columnEditing, handleEditColumnsButtonClick, expectColumnReorder, }) {
50
+ const { columns, columnEditButton } = use(TablePropsContext);
51
+ const { key, name, renderHeader, sortOrder, deletable, canReorder, thClassName } = columns[columnIndex];
52
+ const animatedColumn = use(ColumnAnimationContext);
53
+ const children = renderHeader ? renderHeader() : (name ?? String(key));
54
+ return (<th className={classNames(styles.headerCell, animatedColumn?.columnIndex === columnIndex && animatedColumn.cellClassName, thClassName)} scope='col' aria-sort={sortOrder}>
55
+ <div className={styles.headerCellInnerWrapper}>
56
+ <div className={styles.sortAndHeader}>
57
+ {canReorder && <ColumnReorderHandle columnIndex={columnIndex} expectColumnReorder={expectColumnReorder}/>}
58
+ {sortOrder ? (<SortButton columnIndex={columnIndex} aria-description={`Sort order: ${sortOrder}`}>
59
+ {children}
60
+ </SortButton>) : (children)}
61
+ {canReorder && <ColumnReorderHandleMirror />}
62
+ </div>
63
+
64
+ <div className={styles.rightButtons}>
65
+ {deletable && <DeleteColumnButton columnIndex={columnIndex}/>}
66
+ {columnIndex === columns.length - 1 && columnEditButton && (<EditColumnsButton columnEditing={columnEditing} onClick={handleEditColumnsButtonClick}/>)}
67
+ </div>
68
+ </div>
69
+ </th>);
70
+ }
71
+ function SortButton({ columnIndex, className, children, onClick, ...restProps }) {
72
+ const tableProps = use(TablePropsContext);
73
+ const column = tableProps?.columns[columnIndex];
74
+ const sortOrder = column?.sortOrder;
75
+ const glyph = sortOrder === 'none'
76
+ ? unsortedIcon
77
+ : sortOrder === 'ascending'
78
+ ? arrowUpIcon
79
+ : sortOrder === 'descending'
80
+ ? arrowDownIcon
81
+ : undefined;
82
+ const handleClick = useCallback((e) => {
83
+ onClick?.(e);
84
+ if (!e.defaultPrevented) {
85
+ const nextOrder = sortOrder === 'ascending' ? 'descending' : sortOrder === 'descending' ? 'none' : 'ascending';
86
+ tableProps.onSort?.(columnIndex, nextOrder, tableProps.columns);
87
+ }
88
+ }, [columnIndex, onClick, sortOrder, tableProps]);
89
+ if (!tableProps || !column) {
90
+ return null;
91
+ }
92
+ return (<button type='button' className={classNames(styles.headerButton, className)} onClick={handleClick} {...restProps}>
93
+ {children} <Icon glyph={glyph} aria-hidden/>
94
+ </button>);
95
+ }
96
+ function DeleteColumnButton({ columnIndex, className, onClick, ...restProps }) {
97
+ const tableProps = use(TablePropsContext);
98
+ const column = tableProps?.columns[columnIndex];
99
+ const handleClick = useCallback((e) => {
100
+ onClick?.(e);
101
+ if (!e.defaultPrevented) {
102
+ tableProps.onColumnDelete?.(columnIndex, tableProps.columns);
103
+ }
104
+ }, [columnIndex, onClick, tableProps]);
105
+ if (!tableProps || !column) {
106
+ return null;
107
+ }
108
+ const hint = `Delete column ${column.name ?? String(column.key)}.`;
109
+ return (<button type='button' className={classNames(styles.headerButton, styles.deleteColumnButton, className)} onClick={handleClick} aria-label={hint} title={hint} {...restProps}>
110
+ <Icon glyph={trashIcon} aria-hidden='true'/>
111
+ </button>);
112
+ }
113
+ function EditColumnsButton({ columnEditing, ...props }) {
114
+ const { className, ...restProps } = props;
115
+ const hint = columnEditing ? 'Hide column controls.' : 'Show column controls.';
116
+ return (<button type='button' className={classNames(styles.headerButton, styles.editColumnsButton, className)} aria-pressed={columnEditing} aria-label={hint} title={hint} {...restProps}>
117
+ <Icon glyph={settingsIcon} aria-hidden='true'/>
118
+ </button>);
119
+ }
120
+ function ColumnReorderHandle({ columnIndex, expectColumnReorder, ref: userRef, className, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture, onKeyDown, ...restProps }) {
121
+ const tableProps = use(TablePropsContext);
122
+ const column = tableProps?.columns[columnIndex];
123
+ const canReorder = column?.canReorder;
124
+ const canReorderCb = useCallback((index) => canReorder === true || (typeof canReorder === 'function' && canReorder(index, tableProps.columns)), [canReorder, tableProps]);
125
+ const localRef = useRef(null);
126
+ const composedRef = useComposedRef(localRef, userRef);
127
+ const activeDragRef = useRef(null);
128
+ const getDragFrame = useCallback(() => {
129
+ return document.body.querySelector(`.${styles.columnDragFrame}`);
130
+ }, []);
131
+ const renderDragFrame = useCallback((clientX) => {
132
+ const { startColumnIndex, startClientX, headerTopClientY, columnsClientX, indicatorHeight } = activeDragRef.current;
133
+ const { l, r } = columnsClientX[startColumnIndex];
134
+ let dragFrame = getDragFrame();
135
+ if (!dragFrame) {
136
+ dragFrame = document.createElement('div');
137
+ dragFrame.className = styles.columnDragFrame;
138
+ dragFrame.style.setProperty('top', `calc(max(0px, ${headerTopClientY - 2}px))`);
139
+ dragFrame.style.setProperty('width', `${r - l}px`);
140
+ dragFrame.style.setProperty('height', indicatorHeight);
141
+ document.body.appendChild(dragFrame);
142
+ }
143
+ dragFrame.style.setProperty('left', `${l + clientX - startClientX}px`);
144
+ }, [getDragFrame]);
145
+ const translateXButton = useCallback((clientX) => {
146
+ if (!localRef.current || !activeDragRef.current)
147
+ return;
148
+ const { startClientX } = activeDragRef.current;
149
+ localRef.current.style.setProperty('transform', `translateX(${clientX - startClientX}px)`);
150
+ }, []);
151
+ const getClosestInsertionPoint = useCallback((clientX) => {
152
+ let bestDistance = Infinity;
153
+ let index = -1;
154
+ let after = false;
155
+ activeDragRef.current?.columnsClientX.forEach(({ l, r }, i) => {
156
+ const distanceToLeft = Math.abs(l - clientX);
157
+ const distanceToRight = Math.abs(r - clientX);
158
+ if (distanceToLeft < bestDistance && canReorderCb(i)) {
159
+ bestDistance = distanceToLeft;
160
+ index = i;
161
+ after = false;
162
+ }
163
+ if (distanceToRight < bestDistance && canReorderCb(i + 1)) {
164
+ bestDistance = distanceToRight;
165
+ index = i;
166
+ after = true;
167
+ }
168
+ });
169
+ return { index, after };
170
+ }, [canReorderCb]);
171
+ const getInsertionIndicator = useCallback(() => {
172
+ return document.body.querySelector(`.${styles.columnInsertionIndicator}`);
173
+ }, []);
174
+ const renderInsertionIndicator = useCallback((insertionPoint) => {
175
+ const { index, after } = insertionPoint;
176
+ const { headerTopClientY, columnsClientX, indicatorHeight } = activeDragRef.current;
177
+ const { l, r } = columnsClientX[index];
178
+ let indicator = getInsertionIndicator();
179
+ if (!indicator) {
180
+ indicator = document.createElement('div');
181
+ indicator.className = styles.columnInsertionIndicator;
182
+ indicator.style.setProperty('top', `${headerTopClientY}px`);
183
+ indicator.style.setProperty('height', indicatorHeight);
184
+ document.body.appendChild(indicator);
185
+ }
186
+ indicator.style.setProperty('left', `${(after ? r : l) - 1}px`);
187
+ }, [getInsertionIndicator]);
188
+ const cleanupDrag = useCallback(() => {
189
+ if (activeDragRef.current) {
190
+ activeDragRef.current.cleanup();
191
+ activeDragRef.current = null;
192
+ }
193
+ const btn = localRef.current;
194
+ if (btn) {
195
+ btn.style.removeProperty('transform');
196
+ btn.style.removeProperty('transition');
197
+ }
198
+ getDragFrame()?.remove();
199
+ getInsertionIndicator()?.remove();
200
+ }, [getDragFrame, getInsertionIndicator]);
201
+ const animateNoChangeThenCleanup = useCallback(() => {
202
+ if (activeDragRef.current?.state === 'is-dragging') {
203
+ activeDragRef.current.state = 'ended-with-no-change';
204
+ activeDragRef.current.cleanup();
205
+ activeDragRef.current.cleanup = () => { };
206
+ const dragFrame = getDragFrame();
207
+ if (dragFrame) {
208
+ const { columnsClientX, startColumnIndex } = activeDragRef.current;
209
+ dragFrame.style.left = `${columnsClientX[startColumnIndex].l}px`;
210
+ dragFrame.style.opacity = '0';
211
+ dragFrame.style.transition = 'left var(--ring-ease), opacity var(--ring-ease)';
212
+ }
213
+ const indicator = getInsertionIndicator();
214
+ if (indicator) {
215
+ indicator.style.opacity = '0';
216
+ indicator.style.transition = 'opacity var(--ring-ease)';
217
+ }
218
+ const btn = localRef.current;
219
+ if (btn) {
220
+ btn.style.transform = 'translateX(0)';
221
+ btn.style.transition = 'transform var(--ring-ease)';
222
+ }
223
+ }
224
+ const ringEaseMs = parseCssDuration(window.getComputedStyle(document.documentElement).getPropertyValue('--ring-ease'));
225
+ setTimeout(cleanupDrag, ringEaseMs);
226
+ }, [cleanupDrag, getDragFrame, getInsertionIndicator]);
227
+ const handlePointerDown = useCallback((e) => {
228
+ onPointerDown?.(e);
229
+ if (e.defaultPrevented)
230
+ return;
231
+ const { clientX: startClientX, pointerId, currentTarget } = e;
232
+ const thead = currentTarget.closest('thead');
233
+ const table = thead?.closest('table');
234
+ if (!thead || !table)
235
+ return;
236
+ const headerTopClientY = thead.getBoundingClientRect().top;
237
+ const columnsClientX = [...thead.querySelectorAll('th')].map(th => {
238
+ const rect = th.getBoundingClientRect();
239
+ return { l: rect.x, r: rect.x + rect.width };
240
+ });
241
+ const { bottom } = table.getBoundingClientRect();
242
+ const visibleTableHeight = bottom - headerTopClientY;
243
+ const viewportBottomRelativeToHeaderTop = window.innerHeight - headerTopClientY;
244
+ const indicatorHeight = `min(${visibleTableHeight}px, calc(${viewportBottomRelativeToHeaderTop}px - .5rem))`;
245
+ currentTarget.setPointerCapture(pointerId);
246
+ function keydownListener(keyEvent) {
247
+ if (keyEvent.key === 'Escape') {
248
+ animateNoChangeThenCleanup();
249
+ keyEvent.stopPropagation();
250
+ keyEvent.preventDefault();
251
+ }
252
+ }
253
+ document.addEventListener('keydown', keydownListener); // In Safari, the button is not focused
254
+ currentTarget.style.cursor = 'grabbing';
255
+ activeDragRef.current = {
256
+ state: 'is-dragging',
257
+ startColumnIndex: columnIndex,
258
+ startClientX,
259
+ headerTopClientY,
260
+ columnsClientX,
261
+ indicatorHeight,
262
+ cleanup: () => {
263
+ document.removeEventListener('keydown', keydownListener);
264
+ currentTarget.releasePointerCapture(pointerId);
265
+ currentTarget.style.removeProperty('cursor');
266
+ },
267
+ };
268
+ renderDragFrame(startClientX);
269
+ e.preventDefault();
270
+ }, [animateNoChangeThenCleanup, columnIndex, onPointerDown, renderDragFrame]);
271
+ const handlePointerMove = useCallback((e) => {
272
+ onPointerMove?.(e);
273
+ if (e.defaultPrevented || activeDragRef.current?.state !== 'is-dragging')
274
+ return;
275
+ const { clientX } = e;
276
+ renderDragFrame(clientX);
277
+ translateXButton(clientX);
278
+ const insertionPoint = getClosestInsertionPoint(clientX);
279
+ if (insertionPoint)
280
+ renderInsertionIndicator(insertionPoint);
281
+ }, [getClosestInsertionPoint, translateXButton, onPointerMove, renderDragFrame, renderInsertionIndicator]);
282
+ const handlePointerUp = useCallback((e) => {
283
+ onPointerUp?.(e);
284
+ if (e.defaultPrevented || activeDragRef.current?.state !== 'is-dragging')
285
+ return;
286
+ const table = e.currentTarget.closest('table');
287
+ if (!table) {
288
+ cleanupDrag();
289
+ return;
290
+ }
291
+ const { index, after } = getClosestInsertionPoint(e.clientX);
292
+ const insertionIndex = after ? index + 1 : index;
293
+ if (insertionIndex === columnIndex || insertionIndex === columnIndex + 1) {
294
+ animateNoChangeThenCleanup();
295
+ return;
296
+ }
297
+ cleanupDrag();
298
+ expectColumnReorder({ fromIndex: columnIndex, insertionIndex });
299
+ tableProps.onColumnReorder?.(columnIndex, insertionIndex, tableProps.columns);
300
+ }, [
301
+ animateNoChangeThenCleanup,
302
+ cleanupDrag,
303
+ columnIndex,
304
+ expectColumnReorder,
305
+ getClosestInsertionPoint,
306
+ onPointerUp,
307
+ tableProps,
308
+ ]);
309
+ const handlePointerCancel = useCallback((e) => {
310
+ onPointerCancel?.(e);
311
+ if (e.defaultPrevented)
312
+ return;
313
+ animateNoChangeThenCleanup();
314
+ }, [animateNoChangeThenCleanup, onPointerCancel]);
315
+ const handleLostPointerCapture = useCallback((e) => {
316
+ onLostPointerCapture?.(e);
317
+ if (e.defaultPrevented)
318
+ return;
319
+ animateNoChangeThenCleanup();
320
+ }, [animateNoChangeThenCleanup, onLostPointerCapture]);
321
+ const handleKeyDown = useCallback((e) => {
322
+ onKeyDown?.(e);
323
+ if (e.defaultPrevented || !tableProps)
324
+ return;
325
+ const left = e.key === 'ArrowLeft';
326
+ const right = e.key === 'ArrowRight';
327
+ if (!left && !right)
328
+ return;
329
+ const initialInsertionIndex = left ? columnIndex - 1 : columnIndex + 2;
330
+ const step = left ? -1 : 1;
331
+ // eslint-disable-next-line yoda
332
+ for (let i = initialInsertionIndex; 0 <= i && i <= tableProps.columns.length; i += step) {
333
+ if (canReorderCb(i)) {
334
+ expectColumnReorder({ fromIndex: columnIndex, insertionIndex: i });
335
+ tableProps.onColumnReorder?.(columnIndex, i, tableProps.columns);
336
+ e.preventDefault();
337
+ return;
338
+ }
339
+ }
340
+ }, [canReorderCb, columnIndex, expectColumnReorder, onKeyDown, tableProps]);
341
+ if (!tableProps || !column) {
342
+ return null;
343
+ }
344
+ const hint = `Reorder column ${column.name ?? String(column.key)}.`;
345
+ const description = 'Use Left and Right arrow keys to move the column.';
346
+ return (
347
+ // eslint-disable-next-line jsx-a11y/role-supports-aria-props
348
+ <button ref={composedRef} type='button' className={classNames(styles.headerButton, styles.columnReorderHandle, className)} aria-label={hint} aria-description={description} aria-keyshortcuts='ArrowLeft ArrowRight' onKeyDown={handleKeyDown} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} onLostPointerCapture={handleLostPointerCapture} {...restProps}>
349
+ <Icon glyph={dragIcon} aria-hidden='true'/>
350
+ </button>);
351
+ }
352
+ /**
353
+ * Reserves the space for the reorder handle and prevents layout shift when the handle appears on hover.
354
+ */
355
+ function ColumnReorderHandleMirror() {
356
+ return <span className={styles.columnReorderHandleMirror} aria-hidden='true'/>;
357
+ }
@@ -0,0 +1,34 @@
1
+ import { type RefObject } from 'react';
2
+ export type VirtualItem = MaterializedItem | Spacer;
3
+ interface MaterializedItem {
4
+ type: 'materialized';
5
+ index: number;
6
+ }
7
+ interface Spacer {
8
+ type: 'spacer';
9
+ from: number;
10
+ to: number;
11
+ height: number;
12
+ key: string;
13
+ }
14
+ type CollapseItemIntoSpacerCallback = (index: number, height: number) => void;
15
+ export declare const CollapseItemIntoSpacerContext: import("react").Context<CollapseItemIntoSpacerCallback>;
16
+ export declare function useVirtualItems<T>({ enabled, data, data: { length }, scrollerRef, tableRef, estimateHeight, lookaheadPx, retentionMarginPx, minScrollAndResizeDeltaPx, }: {
17
+ enabled: boolean;
18
+ data: readonly T[];
19
+ scrollerRef: RefObject<HTMLElement | null> | undefined;
20
+ tableRef: RefObject<HTMLTableElement | null>;
21
+ estimateHeight: (item: T, index: number, items: readonly T[]) => number;
22
+ lookaheadPx: number;
23
+ retentionMarginPx: number;
24
+ minScrollAndResizeDeltaPx: number;
25
+ }): {
26
+ virtualItems: VirtualItem[];
27
+ intersectionObserverHandle: import("../../global/intersection-observer-context").IntersectionObserverHandle;
28
+ collapseItemIntoSpacer: CollapseItemIntoSpacerCallback;
29
+ };
30
+ export declare function SpacerRow({ spacer: { from, to, height }, colSpan }: {
31
+ spacer: Spacer;
32
+ colSpan: number;
33
+ }): import("react").JSX.Element;
34
+ export {};
@@ -1,52 +1,45 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { useIntersectionObserverHandle } from '../global/intersection-observer-context';
3
- import styles from './table.css';
1
+ import { createContext, useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useIntersectionObserverHandle } from '../../global/intersection-observer-context';
3
+ import { setTimeoutWithCleanup } from '../../global/schedule-with-cleanup';
4
+ import styles from '../table.css';
5
+ export const CollapseItemIntoSpacerContext = createContext(() => { });
4
6
  /**
5
7
  * RAF is somewhat too frequent. Most updates happen on virtualization boundaries
6
8
  * within the invisible overscroll area, so they don't require such a high update rate.
7
9
  * Therefore, we throttle with a custom delay.
8
10
  */
9
11
  const virtualizationThrottleDelay = 50;
10
- export function useTableVirtualize({ enabled, data, data: { length }, scrollerRef, tableRef, estimateHeight, lookaheadPx, retentionMarginPx, minScrollAndResizeDeltaPx, }) {
12
+ export function useVirtualItems({ enabled, data, data: { length }, scrollerRef, tableRef, estimateHeight, lookaheadPx, retentionMarginPx, minScrollAndResizeDeltaPx, }) {
11
13
  const itemsMaterialization = useRef([]);
12
- const [virtualItems, setVirtualItems] = useState(() => [
13
- {
14
- type: 'spacer',
15
- from: 0,
16
- to: length,
17
- height: data.reduce((acc, item, index, items) => acc + estimateHeight(item, index, items), 0),
18
- key: `${styles.spacerRow}-0`,
19
- },
20
- ]);
14
+ const [virtualItems, setVirtualItems] = useState(() => enabled ? getAllVirtualizedItems(data, estimateHeight) : []);
21
15
  const materializeVisibleSpacerItems = useCallback(() => {
22
16
  if (!tableRef.current)
23
17
  return;
24
- const containerHeight = scrollerRef?.current?.clientHeight ?? window.innerHeight;
18
+ const scrollerRect = scrollerRef?.current?.getBoundingClientRect();
19
+ const visibleStart = scrollerRect?.top ?? 0;
20
+ const visibleEnd = scrollerRect ? Math.min(scrollerRect.bottom, window.innerHeight) : window.innerHeight;
21
+ const materializeStart = visibleStart - lookaheadPx;
22
+ const materializeEnd = visibleEnd + lookaheadPx;
25
23
  for (const spacerRow of tableRef.current.querySelectorAll(`.${styles.spacerRow}`)) {
26
- const rect = spacerRow.getBoundingClientRect();
27
- const spacerIntersectsLookaheadArea = rect.top < containerHeight + lookaheadPx && rect.bottom > -lookaheadPx;
28
- if (!spacerIntersectsLookaheadArea) {
24
+ const spacerRect = spacerRow.getBoundingClientRect();
25
+ if (spacerRect.bottom < materializeStart || spacerRect.top > materializeEnd) {
29
26
  continue;
30
27
  }
31
- const visibleOffsetStart = Math.max(0, -rect.top);
32
- const visibleOffsetEnd = Math.min(rect.height, containerHeight - rect.top);
33
- const materializeOffsetStart = visibleOffsetStart - lookaheadPx;
34
- const materializeOffsetEnd = visibleOffsetEnd + lookaheadPx;
35
- let offsetInSpacer = 0;
36
28
  const from = Number(spacerRow.dataset.from);
37
29
  const to = Number(spacerRow.dataset.to);
30
+ let currentTop = spacerRect.top;
38
31
  for (let i = from; i < to; i++) {
39
32
  const itemMaterialization = itemsMaterialization.current[i];
40
33
  const itemHeight = typeof itemMaterialization === 'number' ? itemMaterialization : estimateHeight(data[i], i, data);
41
- const itemOffsetStart = offsetInSpacer;
42
- const itemOffsetEnd = offsetInSpacer + itemHeight;
43
- if (itemOffsetStart < materializeOffsetEnd && itemOffsetEnd > materializeOffsetStart) {
34
+ const itemTop = currentTop;
35
+ const itemBottom = currentTop + itemHeight;
36
+ if (itemBottom >= materializeStart && itemTop <= materializeEnd) {
44
37
  itemsMaterialization.current[i] = true;
45
38
  }
46
- else if (itemOffsetStart > materializeOffsetEnd) {
39
+ else if (itemTop > materializeEnd) {
47
40
  break;
48
41
  }
49
- offsetInSpacer += itemHeight;
42
+ currentTop += itemHeight;
50
43
  }
51
44
  }
52
45
  }, [data, estimateHeight, lookaheadPx, scrollerRef, tableRef]);
@@ -56,7 +49,7 @@ export function useTableVirtualize({ enabled, data, data: { length }, scrollerRe
56
49
  for (let i = 0; i < length; i++) {
57
50
  const itemMaterialization = itemsMaterialization.current[i];
58
51
  if (itemMaterialization === true) {
59
- newVirtualItems.push({ type: 'rendered', index: i });
52
+ newVirtualItems.push({ type: 'materialized', index: i });
60
53
  }
61
54
  else {
62
55
  const lastItemOrSpacer = newVirtualItems[newVirtualItems.length - 1];
@@ -72,7 +65,7 @@ export function useTableVirtualize({ enabled, data, data: { length }, scrollerRe
72
65
  from: i,
73
66
  to: i + 1,
74
67
  height,
75
- key: `${styles.spacerRow}-${spacerCounter++}`,
68
+ key: spacerKey(spacerCounter++),
76
69
  });
77
70
  }
78
71
  }
@@ -96,7 +89,10 @@ export function useTableVirtualize({ enabled, data, data: { length }, scrollerRe
96
89
  callbacksRef.current = null;
97
90
  }, virtualizationThrottleDelay);
98
91
  }, []);
92
+ const prevEnabledRef = useRef(enabled);
99
93
  useEffect(() => {
94
+ const prevEnabled = prevEnabledRef.current;
95
+ prevEnabledRef.current = enabled;
100
96
  if (!enabled)
101
97
  return;
102
98
  const scroller = scrollerRef?.current;
@@ -121,13 +117,39 @@ export function useTableVirtualize({ enabled, data, data: { length }, scrollerRe
121
117
  const resizeObserver = new ResizeObserver(handleViewportChange);
122
118
  const resizeTarget = scroller ?? document.documentElement;
123
119
  resizeObserver.observe(resizeTarget);
124
- handleViewportChange();
120
+ let timeoutCleanup;
121
+ if (!prevEnabled) {
122
+ timeoutCleanup = setTimeoutWithCleanup(() => {
123
+ itemsMaterialization.current = [];
124
+ setVirtualItems(getAllVirtualizedItems(data, estimateHeight));
125
+ handleViewportChange();
126
+ });
127
+ }
128
+ else {
129
+ handleViewportChange();
130
+ }
125
131
  return () => {
132
+ timeoutCleanup?.();
126
133
  scrollTarget.removeEventListener('scroll', handleViewportChange);
127
134
  resizeObserver.unobserve(resizeTarget);
128
135
  resizeObserver.disconnect();
136
+ if (timerIdRef.current != null) {
137
+ window.clearTimeout(timerIdRef.current);
138
+ timerIdRef.current = null;
139
+ callbacksRef.current = null;
140
+ }
129
141
  };
130
- }, [enabled, materializeVisibleSpacerItems, minScrollAndResizeDeltaPx, recomputeVirtualItems, scrollerRef, throttle]);
142
+ }, [
143
+ data,
144
+ enabled,
145
+ estimateHeight,
146
+ length,
147
+ materializeVisibleSpacerItems,
148
+ minScrollAndResizeDeltaPx,
149
+ recomputeVirtualItems,
150
+ scrollerRef,
151
+ throttle,
152
+ ]);
131
153
  const intersectionObserverHandle = useIntersectionObserverHandle(scrollerRef, scrollerRef ? retentionMarginPx : undefined, !scrollerRef ? retentionMarginPx : undefined);
132
154
  const collapseItemIntoSpacer = useCallback((index, height) => {
133
155
  if (!enabled)
@@ -135,13 +157,26 @@ export function useTableVirtualize({ enabled, data, data: { length }, scrollerRe
135
157
  itemsMaterialization.current[index] = height;
136
158
  throttle(recomputeVirtualItems);
137
159
  }, [enabled, throttle, recomputeVirtualItems]);
138
- const allVisibleVirtualItems = useMemo(() => Array.from({ length: enabled ? 0 : length }, (_, index) => ({ type: 'rendered', index })), [enabled, length]);
139
160
  return {
140
- virtualItems: enabled ? virtualItems : allVisibleVirtualItems,
161
+ virtualItems,
141
162
  intersectionObserverHandle,
142
163
  collapseItemIntoSpacer,
143
164
  };
144
165
  }
166
+ function getAllVirtualizedItems(data, estimateHeight) {
167
+ return [
168
+ {
169
+ type: 'spacer',
170
+ from: 0,
171
+ to: data.length,
172
+ height: data.reduce((acc, item, index, items) => acc + estimateHeight(item, index, items), 0),
173
+ key: spacerKey(0),
174
+ },
175
+ ];
176
+ }
177
+ function spacerKey(index) {
178
+ return `__${styles.spacerRow}-${index}`;
179
+ }
145
180
  export function SpacerRow({ spacer: { from, to, height }, colSpan }) {
146
181
  return (<tr className={styles.spacerRow} data-from={from} data-to={to}>
147
182
  <td className={styles.spacerCell} colSpan={colSpan} style={{ height }}/>
@@ -0,0 +1,35 @@
1
+ import { type RefObject } from 'react';
2
+ /**
3
+ * Use in an item renderer to control item virtualization.
4
+ */
5
+ export declare function useItemVirtualization({ index, refs, onIntersectionChange, }: {
6
+ /**
7
+ * Index of the item.
8
+ */
9
+ index: number;
10
+ /**
11
+ * One or more elements representing this item.
12
+ *
13
+ * When multiple elements are provided, the virtualization callback receives
14
+ * the intersection state of all of them.
15
+ *
16
+ * If you pass multiple refs, memoize the array (for example with `useMemo()`)
17
+ * to avoid restarting observation on every render.
18
+ */
19
+ refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[];
20
+ /**
21
+ * Invoked when the `isIntersecting` state of the observed elements changes.
22
+ * Consider wrapping a callback to `useCallback()` to avoid restarting observation on every render.
23
+ *
24
+ * @param intersectionStates - Current intersection state of every observed element.
25
+ * Entries are initially `undefined` until the corresponding element
26
+ * has been reported by `IntersectionObserver`.
27
+ * @param changedIndex - Index of the element whose intersection state changed.
28
+ * @param elements - The observed elements. Note that some elements may
29
+ * already be disconnected from the DOM when this callback is invoked.
30
+ * Use additional checks like `element.isConnected`.
31
+ * @returns Return the height of an item to collapse the item into spacer,
32
+ * or `undefined` to keep the item rendered.
33
+ */
34
+ onIntersectionChange: (intersectionStates: (boolean | undefined)[], changedIndex: number, elements: (Element | null)[]) => number | undefined;
35
+ }): void;
@@ -0,0 +1,28 @@
1
+ import { use, useEffect } from 'react';
2
+ import { IntersectionObserverContext } from '../global/intersection-observer-context';
3
+ import { CollapseItemIntoSpacerContext } from './internal/virtual-items';
4
+ /**
5
+ * Use in an item renderer to control item virtualization.
6
+ */
7
+ export function useItemVirtualization({ index, refs, onIntersectionChange, }) {
8
+ const handle = use(IntersectionObserverContext);
9
+ const collapseItemIntoSpacer = use(CollapseItemIntoSpacerContext);
10
+ useEffect(() => {
11
+ const intersectionStates = Array.isArray(refs) ? refs.map(() => undefined) : [undefined];
12
+ const elements = Array.isArray(refs) ? refs.map(r => r.current) : [refs.current];
13
+ const cleanups = [];
14
+ elements.forEach((element, elementIndex) => {
15
+ if (!element)
16
+ return;
17
+ const cleanup = handle.observe(element, isIntersecting => {
18
+ intersectionStates[elementIndex] = isIntersecting;
19
+ const height = onIntersectionChange(intersectionStates, elementIndex, elements);
20
+ if (height != null) {
21
+ collapseItemIntoSpacer(index, height);
22
+ }
23
+ });
24
+ cleanups.push(cleanup);
25
+ });
26
+ return () => cleanups.forEach(cleanup => cleanup());
27
+ }, [collapseItemIntoSpacer, handle, index, onIntersectionChange, refs]);
28
+ }