@octanejs/window 0.0.1
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/LICENSE.md +21 -0
- package/README.md +89 -0
- package/UPSTREAM.md +116 -0
- package/package.json +68 -0
- package/src/components/grid/Grid.tsx +329 -0
- package/src/components/grid/types.ts +334 -0
- package/src/components/grid/useGridCallbackRef.ts +16 -0
- package/src/components/grid/useGridRef.ts +12 -0
- package/src/components/list/List.tsx +216 -0
- package/src/components/list/isDynamicRowHeight.ts +10 -0
- package/src/components/list/types.ts +214 -0
- package/src/components/list/useDynamicRowHeight.ts +170 -0
- package/src/components/list/useListCallbackRef.ts +16 -0
- package/src/components/list/useListRef.ts +12 -0
- package/src/constants.ts +3 -0
- package/src/core/createCachedBounds.ts +65 -0
- package/src/core/getEstimatedSize.ts +25 -0
- package/src/core/getOffsetForIndex.ts +75 -0
- package/src/core/getStartStopIndices.ts +68 -0
- package/src/core/types.ts +14 -0
- package/src/core/useCachedBounds.ts +29 -0
- package/src/core/useIsRtl.ts +27 -0
- package/src/core/useItemSize.ts +33 -0
- package/src/core/useVirtualizer.ts +269 -0
- package/src/hooks/useIsomorphicLayoutEffect.ts +10 -0
- package/src/hooks/useMemoizedObject.ts +14 -0
- package/src/hooks/useResizeObserver.ts +98 -0
- package/src/hooks/useStableCallback.ts +39 -0
- package/src/index.ts +20 -0
- package/src/internal.ts +37 -0
- package/src/types.ts +5 -0
- package/src/utils/adjustScrollOffsetForRtl.ts +35 -0
- package/src/utils/areArraysEqual.ts +13 -0
- package/src/utils/arePropsEqual.ts +19 -0
- package/src/utils/assert.ts +10 -0
- package/src/utils/colors/getContrastColor.ts +47 -0
- package/src/utils/colors/stringToColor.ts +13 -0
- package/src/utils/debug.ts +13 -0
- package/src/utils/getRTLOffsetType.ts +46 -0
- package/src/utils/getScrollbarSize.ts +23 -0
- package/src/utils/isRtl.ts +12 -0
- package/src/utils/parseNumericStyleValue.ts +17 -0
- package/src/utils/shallowCompare.ts +26 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentProps,
|
|
3
|
+
CSSProperties,
|
|
4
|
+
HTMLAttributes,
|
|
5
|
+
ReactElement,
|
|
6
|
+
ReactNode,
|
|
7
|
+
Ref,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import type { TagNames } from '../../types.js';
|
|
10
|
+
|
|
11
|
+
export type DynamicRowHeight = {
|
|
12
|
+
getAverageRowHeight(): number;
|
|
13
|
+
getRowHeight(index: number): number | undefined;
|
|
14
|
+
setRowHeight(index: number, size: number): void;
|
|
15
|
+
observeRowElements: (elements: Element[] | NodeListOf<Element>) => () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ForbiddenKeys = 'ariaAttributes' | 'index' | 'style';
|
|
19
|
+
type ExcludeForbiddenKeys<Type> = {
|
|
20
|
+
[Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ListProps<RowProps extends object, TagName extends TagNames = 'div'> = Omit<
|
|
24
|
+
HTMLAttributes<HTMLDivElement>,
|
|
25
|
+
'onResize'
|
|
26
|
+
> & {
|
|
27
|
+
/**
|
|
28
|
+
* Additional content to be rendered within the list (above cells).
|
|
29
|
+
* This property can be used to render things like overlays or tooltips.
|
|
30
|
+
*/
|
|
31
|
+
children?: ReactNode;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* CSS class name.
|
|
35
|
+
*/
|
|
36
|
+
className?: string;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default height of list for initial render.
|
|
40
|
+
* This value is important for server rendering.
|
|
41
|
+
*/
|
|
42
|
+
defaultHeight?: number;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ref used to interact with this component's imperative API.
|
|
46
|
+
*
|
|
47
|
+
* This API has imperative methods for scrolling and a getter for the outermost DOM element.
|
|
48
|
+
*
|
|
49
|
+
* ℹ️ The `useListRef` and `useListCallbackRef` hooks are exported for convenience use in TypeScript projects.
|
|
50
|
+
*/
|
|
51
|
+
listRef?: Ref<{
|
|
52
|
+
/**
|
|
53
|
+
* Outermost HTML element for the list if mounted and null (if not mounted.
|
|
54
|
+
*/
|
|
55
|
+
get element(): HTMLDivElement | null;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Scrolls the list so that the specified row is visible.
|
|
59
|
+
*
|
|
60
|
+
* @param align Determines the vertical alignment of the element within the list
|
|
61
|
+
* @param behavior Determines whether scrolling is instant or animates smoothly
|
|
62
|
+
* @param index Index of the row to scroll to (0-based)
|
|
63
|
+
*
|
|
64
|
+
* @throws RangeError if an invalid row index is provided
|
|
65
|
+
*/
|
|
66
|
+
scrollToRow(config: {
|
|
67
|
+
align?: 'auto' | 'center' | 'end' | 'smart' | 'start';
|
|
68
|
+
behavior?: 'auto' | 'instant' | 'smooth';
|
|
69
|
+
index: number;
|
|
70
|
+
}): void;
|
|
71
|
+
}>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Callback notified when the List's outermost HTMLElement resizes.
|
|
75
|
+
* This may be used to (re)scroll a row into view.
|
|
76
|
+
*/
|
|
77
|
+
onResize?: (
|
|
78
|
+
size: { height: number; width: number },
|
|
79
|
+
prevSize: { height: number; width: number },
|
|
80
|
+
) => void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Callback notified when the range of visible rows changes.
|
|
84
|
+
*/
|
|
85
|
+
onRowsRendered?: (
|
|
86
|
+
visibleRows: { startIndex: number; stopIndex: number },
|
|
87
|
+
allRows: { startIndex: number; stopIndex: number },
|
|
88
|
+
) => void;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many additional rows to render outside of the visible area.
|
|
92
|
+
* This can reduce visual flickering near the edges of a list when scrolling.
|
|
93
|
+
*/
|
|
94
|
+
overscanCount?: number;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* React component responsible for rendering a row.
|
|
98
|
+
*
|
|
99
|
+
* This component will receive an `index` and `style` prop by default.
|
|
100
|
+
* Additionally it will receive prop values passed to `rowProps`.
|
|
101
|
+
*
|
|
102
|
+
* ℹ️ The prop types for this component are exported as `RowComponentProps`
|
|
103
|
+
*/
|
|
104
|
+
rowComponent: (
|
|
105
|
+
props: {
|
|
106
|
+
ariaAttributes: {
|
|
107
|
+
'aria-posinset': number;
|
|
108
|
+
'aria-setsize': number;
|
|
109
|
+
role: 'listitem';
|
|
110
|
+
};
|
|
111
|
+
index: number;
|
|
112
|
+
style: CSSProperties;
|
|
113
|
+
} & RowProps,
|
|
114
|
+
) => ReactElement | null;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Number of items to be rendered in the list.
|
|
118
|
+
*/
|
|
119
|
+
rowCount: number;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Row height; the following formats are supported:
|
|
123
|
+
* - number of pixels (number)
|
|
124
|
+
* - percentage of the grid's current height (string)
|
|
125
|
+
* - function that returns the row height (in pixels) given an index and `cellProps`
|
|
126
|
+
* - dynamic row height cache returned by the `useDynamicRowHeight` hook
|
|
127
|
+
*
|
|
128
|
+
* ⚠️ Dynamic row heights are not as efficient as predetermined sizes.
|
|
129
|
+
* It's recommended to provide your own height values if they can be determined ahead of time.
|
|
130
|
+
*/
|
|
131
|
+
rowHeight: number | string | ((index: number, cellProps: RowProps) => number) | DynamicRowHeight;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Lists use the row index as a `key` by default.
|
|
135
|
+
* This prop can provide a custom `key` value.
|
|
136
|
+
*
|
|
137
|
+
* ℹ️ Custom keys can ensure better UX for sortable or filterable lists,
|
|
138
|
+
* particularly if your row components are stateful.
|
|
139
|
+
* Refer to the [React documentation](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key) for more info.
|
|
140
|
+
*
|
|
141
|
+
* ⚠️ This prop cannot be auto-memoized because it is called during render.
|
|
142
|
+
* It is important to always `useCallback` for this prop; do not use an inline function.
|
|
143
|
+
*/
|
|
144
|
+
rowKey?: (index: number, data: RowProps) => React.Key;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Additional props to be passed to the row-rendering component.
|
|
148
|
+
* List will automatically re-render rows when values in this object change.
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ This object must not contain `ariaAttributes`, `index`, or `style` props.
|
|
151
|
+
*/
|
|
152
|
+
rowProps: ExcludeForbiddenKeys<RowProps>;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Optional CSS properties.
|
|
156
|
+
* The list of rows will fill the height defined by this style.
|
|
157
|
+
*/
|
|
158
|
+
style?: CSSProperties;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Can be used to override the root HTML element rendered by the List component.
|
|
162
|
+
* The default value is "div", meaning that List renders an HTMLDivElement as its root.
|
|
163
|
+
*
|
|
164
|
+
* ⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed.
|
|
165
|
+
*/
|
|
166
|
+
tagName?: TagName;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export type RowComponent<RowProps extends object> = ListProps<RowProps>['rowComponent'];
|
|
170
|
+
|
|
171
|
+
export type RowComponentProps<RowProps extends object = object> = ComponentProps<
|
|
172
|
+
RowComponent<RowProps>
|
|
173
|
+
>;
|
|
174
|
+
|
|
175
|
+
export type OnRowsRendered = NonNullable<ListProps<object>['onRowsRendered']>;
|
|
176
|
+
|
|
177
|
+
export type CachedBounds = Map<
|
|
178
|
+
number,
|
|
179
|
+
{
|
|
180
|
+
height: number;
|
|
181
|
+
scrollTop: number;
|
|
182
|
+
}
|
|
183
|
+
>;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Imperative List API.
|
|
187
|
+
*
|
|
188
|
+
* ℹ️ The `useListRef` and `useListCallbackRef` hooks are exported for convenience use in TypeScript projects.
|
|
189
|
+
*/
|
|
190
|
+
export interface ListImperativeAPI {
|
|
191
|
+
/**
|
|
192
|
+
* Outermost HTML element for the list if mounted and null (if not mounted.
|
|
193
|
+
*/
|
|
194
|
+
get element(): HTMLDivElement | null;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Scrolls the list so that the specified row is visible.
|
|
198
|
+
*
|
|
199
|
+
* @param align Determines the vertical alignment of the element within the list
|
|
200
|
+
* @param behavior Determines whether scrolling is instant or animates smoothly
|
|
201
|
+
* @param index Index of the row to scroll to (0-based)
|
|
202
|
+
*
|
|
203
|
+
* @throws RangeError if an invalid row index is provided
|
|
204
|
+
*/
|
|
205
|
+
scrollToRow: ({
|
|
206
|
+
align,
|
|
207
|
+
behavior,
|
|
208
|
+
index,
|
|
209
|
+
}: {
|
|
210
|
+
align?: 'auto' | 'center' | 'end' | 'smart' | 'start';
|
|
211
|
+
behavior?: 'auto' | 'instant' | 'smooth';
|
|
212
|
+
index: number;
|
|
213
|
+
}) => void;
|
|
214
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'octane';
|
|
2
|
+
import { useStableCallback } from '../../hooks/useStableCallback.js';
|
|
3
|
+
import { getSlot, subSlot } from '../../internal.js';
|
|
4
|
+
import { assert } from '../../utils/assert.js';
|
|
5
|
+
import { DATA_ATTRIBUTE_LIST_INDEX } from './List.js';
|
|
6
|
+
import type { DynamicRowHeight } from './types.js';
|
|
7
|
+
|
|
8
|
+
export function useDynamicRowHeight(options: {
|
|
9
|
+
defaultRowHeight: number;
|
|
10
|
+
key?: string | number;
|
|
11
|
+
}): DynamicRowHeight;
|
|
12
|
+
export function useDynamicRowHeight(
|
|
13
|
+
{
|
|
14
|
+
defaultRowHeight,
|
|
15
|
+
key,
|
|
16
|
+
}: {
|
|
17
|
+
defaultRowHeight: number;
|
|
18
|
+
key?: string | number;
|
|
19
|
+
},
|
|
20
|
+
...rest: unknown[]
|
|
21
|
+
): DynamicRowHeight {
|
|
22
|
+
const slot = getSlot(rest);
|
|
23
|
+
const [state, setState] = useState<{
|
|
24
|
+
key: string | number | undefined;
|
|
25
|
+
map: Map<number, number>;
|
|
26
|
+
}>(
|
|
27
|
+
{
|
|
28
|
+
key,
|
|
29
|
+
map: new Map(),
|
|
30
|
+
},
|
|
31
|
+
subSlot(slot, 'state'),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (state.key !== key) {
|
|
35
|
+
setState({
|
|
36
|
+
key,
|
|
37
|
+
map: new Map(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { map } = state;
|
|
42
|
+
|
|
43
|
+
const getAverageRowHeight = useCallback(
|
|
44
|
+
() => {
|
|
45
|
+
let totalHeight = 0;
|
|
46
|
+
|
|
47
|
+
map.forEach((height) => {
|
|
48
|
+
totalHeight += height;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (totalHeight === 0) {
|
|
52
|
+
return defaultRowHeight;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return totalHeight / map.size;
|
|
56
|
+
},
|
|
57
|
+
[defaultRowHeight, map],
|
|
58
|
+
subSlot(slot, 'average'),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const getRowHeight = useCallback(
|
|
62
|
+
(index: number) => {
|
|
63
|
+
const measuredHeight = map.get(index);
|
|
64
|
+
if (measuredHeight !== undefined) {
|
|
65
|
+
return measuredHeight;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Temporarily store default height in the cache map to avoid scroll jumps if rowProps change
|
|
69
|
+
// Else rowProps changes can impact the average height, and cause rows to shift up or down within the list
|
|
70
|
+
// see github.com/bvaughn/react-window/issues/863
|
|
71
|
+
map.set(index, defaultRowHeight);
|
|
72
|
+
|
|
73
|
+
return defaultRowHeight;
|
|
74
|
+
},
|
|
75
|
+
[defaultRowHeight, map],
|
|
76
|
+
subSlot(slot, 'height'),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const setRowHeight = useCallback(
|
|
80
|
+
(index: number, size: number) => {
|
|
81
|
+
setState((prevState) => {
|
|
82
|
+
if (prevState.map.get(index) === size) {
|
|
83
|
+
return prevState;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const clonedMap = new Map(prevState.map);
|
|
87
|
+
clonedMap.set(index, size);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
...prevState,
|
|
91
|
+
map: clonedMap,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
[],
|
|
96
|
+
subSlot(slot, 'set-height'),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const resizeObserverCallback = useStableCallback(
|
|
100
|
+
(entries: ResizeObserverEntry[]) => {
|
|
101
|
+
if (entries.length === 0) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
entries.forEach((entry) => {
|
|
106
|
+
const { borderBoxSize, target } = entry;
|
|
107
|
+
|
|
108
|
+
const attribute = target.getAttribute(DATA_ATTRIBUTE_LIST_INDEX);
|
|
109
|
+
assert(attribute !== null, `Invalid ${DATA_ATTRIBUTE_LIST_INDEX} attribute value`);
|
|
110
|
+
|
|
111
|
+
const index = parseInt(attribute);
|
|
112
|
+
|
|
113
|
+
const { blockSize: height } = borderBoxSize[0];
|
|
114
|
+
if (!height) {
|
|
115
|
+
// Ignore heights that have not yet been measured (e.g. <img> elements that have not yet loaded)
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setRowHeight(index, height);
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
subSlot(slot, 'resize-callback'),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const [resizeObserver] = useState(
|
|
126
|
+
() => {
|
|
127
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
128
|
+
return new ResizeObserver(resizeObserverCallback);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
subSlot(slot, 'resize-observer'),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
useEffect(
|
|
135
|
+
() => {
|
|
136
|
+
if (resizeObserver) {
|
|
137
|
+
return () => {
|
|
138
|
+
resizeObserver.disconnect();
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
[resizeObserver],
|
|
143
|
+
subSlot(slot, 'cleanup'),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const observeRowElements = useCallback(
|
|
147
|
+
(elements: Element[] | NodeListOf<Element>) => {
|
|
148
|
+
if (resizeObserver) {
|
|
149
|
+
elements.forEach((element) => resizeObserver.observe(element));
|
|
150
|
+
return () => {
|
|
151
|
+
elements.forEach((element) => resizeObserver.unobserve(element));
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return () => {};
|
|
155
|
+
},
|
|
156
|
+
[resizeObserver],
|
|
157
|
+
subSlot(slot, 'observe-elements'),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return useMemo<DynamicRowHeight>(
|
|
161
|
+
() => ({
|
|
162
|
+
getAverageRowHeight,
|
|
163
|
+
getRowHeight,
|
|
164
|
+
setRowHeight,
|
|
165
|
+
observeRowElements,
|
|
166
|
+
}),
|
|
167
|
+
[getAverageRowHeight, getRowHeight, setRowHeight, observeRowElements],
|
|
168
|
+
subSlot(slot, 'result'),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useState } from 'octane';
|
|
2
|
+
import { getPublicArgument, getSlot, subSlot } from '../../internal.js';
|
|
3
|
+
import type { ListImperativeAPI } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convenience hook to return a properly typed ref callback for the List component.
|
|
7
|
+
*
|
|
8
|
+
* Use this hook when you need to share the ref with another component or hook.
|
|
9
|
+
*/
|
|
10
|
+
export const useListCallbackRef = ((...args: unknown[]) => {
|
|
11
|
+
const slot = getSlot(args);
|
|
12
|
+
const initialValue = getPublicArgument(args, 0) as
|
|
13
|
+
ListImperativeAPI | null | (() => ListImperativeAPI | null) | undefined;
|
|
14
|
+
const [value, setValue] = useState(initialValue, subSlot(slot, 'list-callback-ref'));
|
|
15
|
+
return [value, setValue];
|
|
16
|
+
}) as unknown as typeof import('react').useState<ListImperativeAPI | null>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useRef } from 'octane';
|
|
2
|
+
import { getPublicArgument, getSlot, subSlot } from '../../internal.js';
|
|
3
|
+
import type { ListImperativeAPI } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convenience hook to return a properly typed ref for the List component.
|
|
7
|
+
*/
|
|
8
|
+
export const useListRef = ((...args: unknown[]) => {
|
|
9
|
+
const slot = getSlot(args);
|
|
10
|
+
const initialValue = getPublicArgument(args, 0) as ListImperativeAPI | null | undefined;
|
|
11
|
+
return useRef(initialValue, subSlot(slot, 'list-ref'));
|
|
12
|
+
}) as typeof import('react').useRef<ListImperativeAPI>;
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { assert } from '../utils/assert.js';
|
|
2
|
+
import type { Bounds, CachedBounds, SizeFunction } from './types.js';
|
|
3
|
+
|
|
4
|
+
export function createCachedBounds<Props extends object>({
|
|
5
|
+
itemCount,
|
|
6
|
+
itemProps,
|
|
7
|
+
itemSize,
|
|
8
|
+
}: {
|
|
9
|
+
itemCount: number;
|
|
10
|
+
itemProps: Props;
|
|
11
|
+
itemSize: number | SizeFunction<Props>;
|
|
12
|
+
}): CachedBounds {
|
|
13
|
+
const cache = new Map<number, Bounds>();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
get(index: number) {
|
|
17
|
+
assert(index < itemCount, `Invalid index ${index}`);
|
|
18
|
+
|
|
19
|
+
while (cache.size - 1 < index) {
|
|
20
|
+
const currentIndex = cache.size;
|
|
21
|
+
|
|
22
|
+
let size: number;
|
|
23
|
+
switch (typeof itemSize) {
|
|
24
|
+
case 'function': {
|
|
25
|
+
size = itemSize(currentIndex, itemProps);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
case 'number': {
|
|
29
|
+
size = itemSize;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (currentIndex === 0) {
|
|
35
|
+
cache.set(currentIndex, {
|
|
36
|
+
size,
|
|
37
|
+
scrollOffset: 0,
|
|
38
|
+
});
|
|
39
|
+
} else {
|
|
40
|
+
const previousRowBounds = cache.get(currentIndex - 1);
|
|
41
|
+
assert(
|
|
42
|
+
previousRowBounds !== undefined,
|
|
43
|
+
`Unexpected bounds cache miss for index ${index}`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
cache.set(currentIndex, {
|
|
47
|
+
scrollOffset: previousRowBounds.scrollOffset + previousRowBounds.size,
|
|
48
|
+
size,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const bounds = cache.get(index);
|
|
54
|
+
assert(bounds !== undefined, `Unexpected bounds cache miss for index ${index}`);
|
|
55
|
+
|
|
56
|
+
return bounds;
|
|
57
|
+
},
|
|
58
|
+
set(index: number, bounds: Bounds) {
|
|
59
|
+
cache.set(index, bounds);
|
|
60
|
+
},
|
|
61
|
+
get size() {
|
|
62
|
+
return cache.size;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CachedBounds, SizeFunction } from './types.js';
|
|
2
|
+
import { assert } from '../utils/assert.js';
|
|
3
|
+
|
|
4
|
+
export function getEstimatedSize<Props extends object>({
|
|
5
|
+
cachedBounds,
|
|
6
|
+
itemCount,
|
|
7
|
+
itemSize,
|
|
8
|
+
}: {
|
|
9
|
+
cachedBounds: CachedBounds;
|
|
10
|
+
itemCount: number;
|
|
11
|
+
itemSize: number | SizeFunction<Props>;
|
|
12
|
+
}) {
|
|
13
|
+
if (itemCount === 0) {
|
|
14
|
+
return 0;
|
|
15
|
+
} else if (typeof itemSize === 'number') {
|
|
16
|
+
return itemCount * itemSize;
|
|
17
|
+
} else {
|
|
18
|
+
const bounds = cachedBounds.get(cachedBounds.size === 0 ? 0 : cachedBounds.size - 1);
|
|
19
|
+
assert(bounds !== undefined, 'Unexpected bounds cache miss');
|
|
20
|
+
|
|
21
|
+
const averageItemSize = (bounds.scrollOffset + bounds.size) / cachedBounds.size;
|
|
22
|
+
|
|
23
|
+
return itemCount * averageItemSize;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Align } from '../types.js';
|
|
2
|
+
import { getEstimatedSize } from './getEstimatedSize.js';
|
|
3
|
+
import type { CachedBounds, SizeFunction } from './types.js';
|
|
4
|
+
|
|
5
|
+
export function getOffsetForIndex<Props extends object>({
|
|
6
|
+
align,
|
|
7
|
+
cachedBounds,
|
|
8
|
+
index,
|
|
9
|
+
itemCount,
|
|
10
|
+
itemSize,
|
|
11
|
+
containerScrollOffset,
|
|
12
|
+
containerSize,
|
|
13
|
+
}: {
|
|
14
|
+
align: Align;
|
|
15
|
+
cachedBounds: CachedBounds;
|
|
16
|
+
index: number;
|
|
17
|
+
itemCount: number;
|
|
18
|
+
itemSize: number | SizeFunction<Props>;
|
|
19
|
+
containerScrollOffset: number;
|
|
20
|
+
containerSize: number;
|
|
21
|
+
}) {
|
|
22
|
+
if (index < 0 || index >= itemCount) {
|
|
23
|
+
throw RangeError(`Invalid index specified: ${index}`, {
|
|
24
|
+
cause: `Index ${index} is not within the range of 0 - ${itemCount - 1}`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const estimatedTotalSize = getEstimatedSize({
|
|
29
|
+
cachedBounds,
|
|
30
|
+
itemCount,
|
|
31
|
+
itemSize,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const bounds = cachedBounds.get(index);
|
|
35
|
+
const maxOffset = Math.max(0, Math.min(estimatedTotalSize - containerSize, bounds.scrollOffset));
|
|
36
|
+
const minOffset = Math.max(0, bounds.scrollOffset - containerSize + bounds.size);
|
|
37
|
+
|
|
38
|
+
if (align === 'smart') {
|
|
39
|
+
if (containerScrollOffset >= minOffset && containerScrollOffset <= maxOffset) {
|
|
40
|
+
align = 'auto';
|
|
41
|
+
} else {
|
|
42
|
+
align = 'center';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch (align) {
|
|
47
|
+
case 'start': {
|
|
48
|
+
return maxOffset;
|
|
49
|
+
}
|
|
50
|
+
case 'end': {
|
|
51
|
+
return minOffset;
|
|
52
|
+
}
|
|
53
|
+
case 'center': {
|
|
54
|
+
if (bounds.scrollOffset <= containerSize / 2) {
|
|
55
|
+
// Too near the beginning to center-align
|
|
56
|
+
return 0;
|
|
57
|
+
} else if (bounds.scrollOffset + bounds.size / 2 >= estimatedTotalSize - containerSize / 2) {
|
|
58
|
+
// Too near the end to center-align
|
|
59
|
+
return estimatedTotalSize - containerSize;
|
|
60
|
+
} else {
|
|
61
|
+
return bounds.scrollOffset + bounds.size / 2 - containerSize / 2;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
case 'auto':
|
|
65
|
+
default: {
|
|
66
|
+
if (containerScrollOffset >= minOffset && containerScrollOffset <= maxOffset) {
|
|
67
|
+
return containerScrollOffset;
|
|
68
|
+
} else if (containerScrollOffset < minOffset) {
|
|
69
|
+
return minOffset;
|
|
70
|
+
} else {
|
|
71
|
+
return maxOffset;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CachedBounds } from './types.js';
|
|
2
|
+
|
|
3
|
+
export function getStartStopIndices({
|
|
4
|
+
cachedBounds,
|
|
5
|
+
containerScrollOffset,
|
|
6
|
+
containerSize,
|
|
7
|
+
itemCount,
|
|
8
|
+
overscanCount,
|
|
9
|
+
}: {
|
|
10
|
+
cachedBounds: CachedBounds;
|
|
11
|
+
containerScrollOffset: number;
|
|
12
|
+
containerSize: number;
|
|
13
|
+
itemCount: number;
|
|
14
|
+
overscanCount: number;
|
|
15
|
+
}): {
|
|
16
|
+
startIndexVisible: number;
|
|
17
|
+
stopIndexVisible: number;
|
|
18
|
+
startIndexOverscan: number;
|
|
19
|
+
stopIndexOverscan: number;
|
|
20
|
+
} {
|
|
21
|
+
const maxIndex = itemCount - 1;
|
|
22
|
+
|
|
23
|
+
let startIndexVisible = 0;
|
|
24
|
+
let stopIndexVisible = -1;
|
|
25
|
+
let startIndexOverscan = 0;
|
|
26
|
+
let stopIndexOverscan = -1;
|
|
27
|
+
let currentIndex = 0;
|
|
28
|
+
|
|
29
|
+
while (currentIndex < maxIndex) {
|
|
30
|
+
const bounds = cachedBounds.get(currentIndex);
|
|
31
|
+
|
|
32
|
+
if (bounds.scrollOffset + bounds.size > containerScrollOffset) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
currentIndex++;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
startIndexVisible = currentIndex;
|
|
40
|
+
startIndexOverscan = Math.max(0, startIndexVisible - overscanCount);
|
|
41
|
+
|
|
42
|
+
while (currentIndex < maxIndex) {
|
|
43
|
+
const bounds = cachedBounds.get(currentIndex);
|
|
44
|
+
|
|
45
|
+
if (bounds.scrollOffset + bounds.size >= containerScrollOffset + containerSize) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
currentIndex++;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stopIndexVisible = Math.min(maxIndex, currentIndex);
|
|
53
|
+
stopIndexOverscan = Math.min(itemCount - 1, stopIndexVisible + overscanCount);
|
|
54
|
+
|
|
55
|
+
if (startIndexVisible < 0) {
|
|
56
|
+
startIndexVisible = 0;
|
|
57
|
+
stopIndexVisible = -1;
|
|
58
|
+
startIndexOverscan = 0;
|
|
59
|
+
stopIndexOverscan = -1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
startIndexVisible,
|
|
64
|
+
stopIndexVisible,
|
|
65
|
+
startIndexOverscan,
|
|
66
|
+
stopIndexOverscan,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type Bounds = {
|
|
2
|
+
size: number;
|
|
3
|
+
scrollOffset: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type CachedBounds = {
|
|
7
|
+
get(index: number): Bounds;
|
|
8
|
+
set(index: number, bounds: Bounds): void;
|
|
9
|
+
size: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Direction = 'horizontal' | 'vertical';
|
|
13
|
+
|
|
14
|
+
export type SizeFunction<Props extends object> = (index: number, props: Props) => number;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useMemo } from 'octane';
|
|
2
|
+
import { getSlot, subSlot } from '../internal.js';
|
|
3
|
+
import { createCachedBounds } from './createCachedBounds.js';
|
|
4
|
+
import type { CachedBounds, SizeFunction } from './types.js';
|
|
5
|
+
|
|
6
|
+
export function useCachedBounds<Props extends object>(
|
|
7
|
+
{
|
|
8
|
+
itemCount,
|
|
9
|
+
itemProps,
|
|
10
|
+
itemSize,
|
|
11
|
+
}: {
|
|
12
|
+
itemCount: number;
|
|
13
|
+
itemProps: Props;
|
|
14
|
+
itemSize: number | SizeFunction<Props>;
|
|
15
|
+
},
|
|
16
|
+
...rest: unknown[]
|
|
17
|
+
): CachedBounds {
|
|
18
|
+
const slot = getSlot(rest);
|
|
19
|
+
return useMemo(
|
|
20
|
+
() =>
|
|
21
|
+
createCachedBounds({
|
|
22
|
+
itemCount,
|
|
23
|
+
itemProps,
|
|
24
|
+
itemSize,
|
|
25
|
+
}),
|
|
26
|
+
[itemCount, itemProps, itemSize],
|
|
27
|
+
subSlot(slot, 'bounds'),
|
|
28
|
+
);
|
|
29
|
+
}
|