@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,27 @@
|
|
|
1
|
+
import { useLayoutEffect, useState } from 'octane';
|
|
2
|
+
import type { HTMLAttributes } from 'react';
|
|
3
|
+
import { getSlot, subSlot } from '../internal.js';
|
|
4
|
+
import { isRtl } from '../utils/isRtl.js';
|
|
5
|
+
|
|
6
|
+
export function useIsRtl(
|
|
7
|
+
element: HTMLElement | null,
|
|
8
|
+
dir: HTMLAttributes<HTMLElement>['dir'],
|
|
9
|
+
...rest: unknown[]
|
|
10
|
+
) {
|
|
11
|
+
const slot = getSlot(rest);
|
|
12
|
+
const [value, setValue] = useState(dir === 'rtl', subSlot(slot, 'value'));
|
|
13
|
+
|
|
14
|
+
useLayoutEffect(
|
|
15
|
+
() => {
|
|
16
|
+
if (element) {
|
|
17
|
+
if (!dir) {
|
|
18
|
+
setValue(isRtl(element));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
[dir, element],
|
|
23
|
+
subSlot(slot, 'effect'),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { assert } from '../utils/assert.js';
|
|
2
|
+
import type { SizeFunction } from './types.js';
|
|
3
|
+
|
|
4
|
+
export function useItemSize<Props extends object>({
|
|
5
|
+
containerSize,
|
|
6
|
+
itemSize: itemSizeProp,
|
|
7
|
+
}: {
|
|
8
|
+
containerSize: number;
|
|
9
|
+
itemSize: number | string | SizeFunction<Props>;
|
|
10
|
+
}) {
|
|
11
|
+
let itemSize: number | SizeFunction<Props>;
|
|
12
|
+
switch (typeof itemSizeProp) {
|
|
13
|
+
case 'string': {
|
|
14
|
+
assert(
|
|
15
|
+
itemSizeProp.endsWith('%'),
|
|
16
|
+
`Invalid item size: "${itemSizeProp}"; string values must be percentages (e.g. "100%")`,
|
|
17
|
+
);
|
|
18
|
+
assert(
|
|
19
|
+
containerSize !== undefined,
|
|
20
|
+
'Container size must be defined if a percentage item size is specified',
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
itemSize = (containerSize * parseInt(itemSizeProp)) / 100;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
default: {
|
|
27
|
+
itemSize = itemSizeProp;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return itemSize;
|
|
33
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'octane';
|
|
2
|
+
import type { CSSProperties } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect } from '../hooks/useIsomorphicLayoutEffect.js';
|
|
4
|
+
import { useResizeObserver } from '../hooks/useResizeObserver.js';
|
|
5
|
+
import { useStableCallback } from '../hooks/useStableCallback.js';
|
|
6
|
+
import { getSlot, subSlot } from '../internal.js';
|
|
7
|
+
import type { Align } from '../types.js';
|
|
8
|
+
import { adjustScrollOffsetForRtl } from '../utils/adjustScrollOffsetForRtl.js';
|
|
9
|
+
import { shallowCompare } from '../utils/shallowCompare.js';
|
|
10
|
+
import { getEstimatedSize as getEstimatedSizeUtil } from './getEstimatedSize.js';
|
|
11
|
+
import { getOffsetForIndex } from './getOffsetForIndex.js';
|
|
12
|
+
import { getStartStopIndices as getStartStopIndicesUtil } from './getStartStopIndices.js';
|
|
13
|
+
import type { Direction, SizeFunction } from './types.js';
|
|
14
|
+
import { useCachedBounds } from './useCachedBounds.js';
|
|
15
|
+
import { useItemSize } from './useItemSize.js';
|
|
16
|
+
|
|
17
|
+
export function useVirtualizer<Props extends object>(
|
|
18
|
+
{
|
|
19
|
+
containerElement,
|
|
20
|
+
containerStyle,
|
|
21
|
+
defaultContainerSize = 0,
|
|
22
|
+
direction,
|
|
23
|
+
isRtl = false,
|
|
24
|
+
itemCount,
|
|
25
|
+
itemProps,
|
|
26
|
+
itemSize: itemSizeProp,
|
|
27
|
+
onResize,
|
|
28
|
+
overscanCount,
|
|
29
|
+
}: {
|
|
30
|
+
containerElement: HTMLElement | null;
|
|
31
|
+
containerStyle?: CSSProperties;
|
|
32
|
+
defaultContainerSize?: number;
|
|
33
|
+
direction: Direction;
|
|
34
|
+
isRtl?: boolean;
|
|
35
|
+
itemCount: number;
|
|
36
|
+
itemProps: Props;
|
|
37
|
+
itemSize: number | string | SizeFunction<Props>;
|
|
38
|
+
onResize:
|
|
39
|
+
| ((
|
|
40
|
+
size: { height: number; width: number },
|
|
41
|
+
prevSize: { height: number; width: number },
|
|
42
|
+
) => void)
|
|
43
|
+
| undefined;
|
|
44
|
+
overscanCount: number;
|
|
45
|
+
},
|
|
46
|
+
...rest: unknown[]
|
|
47
|
+
) {
|
|
48
|
+
const slot = getSlot(rest);
|
|
49
|
+
const { height = defaultContainerSize, width = defaultContainerSize } = useResizeObserver(
|
|
50
|
+
{
|
|
51
|
+
defaultHeight: direction === 'vertical' ? defaultContainerSize : undefined,
|
|
52
|
+
defaultWidth: direction === 'horizontal' ? defaultContainerSize : undefined,
|
|
53
|
+
element: containerElement,
|
|
54
|
+
mode: direction === 'vertical' ? 'only-height' : 'only-width',
|
|
55
|
+
style: containerStyle,
|
|
56
|
+
},
|
|
57
|
+
subSlot(slot, 'resize-observer'),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const prevSizeRef = useRef<{ height: number; width: number }>(
|
|
61
|
+
{
|
|
62
|
+
height: 0,
|
|
63
|
+
width: 0,
|
|
64
|
+
},
|
|
65
|
+
subSlot(slot, 'previous-size'),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const containerSize = direction === 'vertical' ? height : width;
|
|
69
|
+
|
|
70
|
+
const itemSize = useItemSize({ containerSize, itemSize: itemSizeProp });
|
|
71
|
+
|
|
72
|
+
useLayoutEffect(
|
|
73
|
+
() => {
|
|
74
|
+
if (typeof onResize === 'function') {
|
|
75
|
+
const prevSize = prevSizeRef.current;
|
|
76
|
+
|
|
77
|
+
if (prevSize.height !== height || prevSize.width !== width) {
|
|
78
|
+
onResize({ height, width }, { ...prevSize });
|
|
79
|
+
|
|
80
|
+
prevSize.height = height;
|
|
81
|
+
prevSize.width = width;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
[height, onResize, width],
|
|
86
|
+
subSlot(slot, 'on-resize'),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const cachedBounds = useCachedBounds(
|
|
90
|
+
{
|
|
91
|
+
itemCount,
|
|
92
|
+
itemProps,
|
|
93
|
+
itemSize,
|
|
94
|
+
},
|
|
95
|
+
subSlot(slot, 'cached-bounds'),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const getCellBounds = useCallback(
|
|
99
|
+
(index: number) => cachedBounds.get(index),
|
|
100
|
+
[cachedBounds],
|
|
101
|
+
subSlot(slot, 'cell-bounds'),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const [indices, setIndices] = useState<{
|
|
105
|
+
startIndexVisible: number;
|
|
106
|
+
stopIndexVisible: number;
|
|
107
|
+
startIndexOverscan: number;
|
|
108
|
+
stopIndexOverscan: number;
|
|
109
|
+
}>(
|
|
110
|
+
() =>
|
|
111
|
+
getStartStopIndicesUtil({
|
|
112
|
+
cachedBounds,
|
|
113
|
+
// TODO Potentially support a defaultScrollOffset prop?
|
|
114
|
+
containerScrollOffset: 0,
|
|
115
|
+
containerSize,
|
|
116
|
+
itemCount,
|
|
117
|
+
overscanCount,
|
|
118
|
+
}),
|
|
119
|
+
subSlot(slot, 'indices'),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Guard against temporarily invalid indices that may occur when item count decreases
|
|
123
|
+
// Cached bounds object will be re-created and a second render will restore things
|
|
124
|
+
const { startIndexVisible, startIndexOverscan, stopIndexVisible, stopIndexOverscan } = {
|
|
125
|
+
startIndexVisible: Math.min(itemCount - 1, indices.startIndexVisible),
|
|
126
|
+
startIndexOverscan: Math.min(itemCount - 1, indices.startIndexOverscan),
|
|
127
|
+
stopIndexVisible: Math.min(itemCount - 1, indices.stopIndexVisible),
|
|
128
|
+
stopIndexOverscan: Math.min(itemCount - 1, indices.stopIndexOverscan),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const getEstimatedSize = useCallback(
|
|
132
|
+
() =>
|
|
133
|
+
getEstimatedSizeUtil({
|
|
134
|
+
cachedBounds,
|
|
135
|
+
itemCount,
|
|
136
|
+
itemSize,
|
|
137
|
+
}),
|
|
138
|
+
[cachedBounds, itemCount, itemSize],
|
|
139
|
+
subSlot(slot, 'estimated-size'),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const getStartStopIndices = useCallback(
|
|
143
|
+
(scrollOffset: number) => {
|
|
144
|
+
const containerScrollOffset = adjustScrollOffsetForRtl({
|
|
145
|
+
containerElement,
|
|
146
|
+
direction,
|
|
147
|
+
isRtl,
|
|
148
|
+
scrollOffset,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return getStartStopIndicesUtil({
|
|
152
|
+
cachedBounds,
|
|
153
|
+
containerScrollOffset,
|
|
154
|
+
containerSize,
|
|
155
|
+
itemCount,
|
|
156
|
+
overscanCount,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
[cachedBounds, containerElement, containerSize, direction, isRtl, itemCount, overscanCount],
|
|
160
|
+
subSlot(slot, 'start-stop'),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
useIsomorphicLayoutEffect(
|
|
164
|
+
() => {
|
|
165
|
+
const scrollOffset =
|
|
166
|
+
(direction === 'vertical' ? containerElement?.scrollTop : containerElement?.scrollLeft) ??
|
|
167
|
+
0;
|
|
168
|
+
|
|
169
|
+
setIndices(getStartStopIndices(scrollOffset));
|
|
170
|
+
},
|
|
171
|
+
[containerElement, direction, getStartStopIndices],
|
|
172
|
+
subSlot(slot, 'initial-scroll'),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
useIsomorphicLayoutEffect(
|
|
176
|
+
() => {
|
|
177
|
+
if (!containerElement) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const onScroll = () => {
|
|
182
|
+
setIndices((prev) => {
|
|
183
|
+
const { scrollLeft, scrollTop } = containerElement;
|
|
184
|
+
|
|
185
|
+
const scrollOffset = adjustScrollOffsetForRtl({
|
|
186
|
+
containerElement,
|
|
187
|
+
direction,
|
|
188
|
+
isRtl,
|
|
189
|
+
scrollOffset: direction === 'vertical' ? scrollTop : scrollLeft,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const next = getStartStopIndicesUtil({
|
|
193
|
+
cachedBounds,
|
|
194
|
+
containerScrollOffset: scrollOffset,
|
|
195
|
+
containerSize,
|
|
196
|
+
itemCount,
|
|
197
|
+
overscanCount,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (shallowCompare(next, prev)) {
|
|
201
|
+
return prev;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return next;
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
containerElement.addEventListener('scroll', onScroll);
|
|
209
|
+
|
|
210
|
+
return () => {
|
|
211
|
+
containerElement.removeEventListener('scroll', onScroll);
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
[cachedBounds, containerElement, containerSize, direction, itemCount, overscanCount],
|
|
215
|
+
subSlot(slot, 'scroll-listener'),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const scrollToIndex = useStableCallback(
|
|
219
|
+
({
|
|
220
|
+
align = 'auto',
|
|
221
|
+
containerScrollOffset,
|
|
222
|
+
index,
|
|
223
|
+
}: {
|
|
224
|
+
align?: Align;
|
|
225
|
+
containerScrollOffset: number;
|
|
226
|
+
index: number;
|
|
227
|
+
}) => {
|
|
228
|
+
let scrollOffset = getOffsetForIndex({
|
|
229
|
+
align,
|
|
230
|
+
cachedBounds,
|
|
231
|
+
containerScrollOffset,
|
|
232
|
+
containerSize,
|
|
233
|
+
index,
|
|
234
|
+
itemCount,
|
|
235
|
+
itemSize,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (containerElement) {
|
|
239
|
+
scrollOffset = adjustScrollOffsetForRtl({
|
|
240
|
+
containerElement,
|
|
241
|
+
direction,
|
|
242
|
+
isRtl,
|
|
243
|
+
scrollOffset,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (typeof containerElement.scrollTo !== 'function') {
|
|
247
|
+
// Special case for environments like jsdom that don't implement scrollTo
|
|
248
|
+
const next = getStartStopIndices(scrollOffset);
|
|
249
|
+
if (!shallowCompare(indices, next)) {
|
|
250
|
+
setIndices(next);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return scrollOffset;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
subSlot(slot, 'scroll-to-index'),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
getCellBounds,
|
|
262
|
+
getEstimatedSize,
|
|
263
|
+
scrollToIndex,
|
|
264
|
+
startIndexOverscan,
|
|
265
|
+
startIndexVisible,
|
|
266
|
+
stopIndexOverscan,
|
|
267
|
+
stopIndexVisible,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect } from 'octane';
|
|
2
|
+
|
|
3
|
+
export function useIsomorphicLayoutEffect(
|
|
4
|
+
effect: () => void | (() => void),
|
|
5
|
+
dependencies: unknown[] | undefined,
|
|
6
|
+
slot?: symbol,
|
|
7
|
+
): void {
|
|
8
|
+
const hook = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
9
|
+
hook(effect, dependencies, slot);
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useMemo } from 'octane';
|
|
2
|
+
import { getSlot, subSlot } from '../internal.js';
|
|
3
|
+
|
|
4
|
+
export function useMemoizedObject<Type extends object>(
|
|
5
|
+
unstableObject: Type,
|
|
6
|
+
...rest: unknown[]
|
|
7
|
+
): Type {
|
|
8
|
+
const slot = getSlot(rest);
|
|
9
|
+
return useMemo(
|
|
10
|
+
() => unstableObject,
|
|
11
|
+
Object.values(unstableObject),
|
|
12
|
+
subSlot(slot, 'memoized-object'),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useMemo, useState } from 'octane';
|
|
2
|
+
import type { CSSProperties } from 'react';
|
|
3
|
+
import { getSlot, subSlot } from '../internal.js';
|
|
4
|
+
import { parseNumericStyleValue } from '../utils/parseNumericStyleValue.js';
|
|
5
|
+
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect.js';
|
|
6
|
+
|
|
7
|
+
export function useResizeObserver(
|
|
8
|
+
{
|
|
9
|
+
box,
|
|
10
|
+
defaultHeight,
|
|
11
|
+
defaultWidth,
|
|
12
|
+
disabled: disabledProp,
|
|
13
|
+
element,
|
|
14
|
+
mode,
|
|
15
|
+
style,
|
|
16
|
+
}: {
|
|
17
|
+
box?: ResizeObserverBoxOptions;
|
|
18
|
+
defaultHeight?: number;
|
|
19
|
+
defaultWidth?: number;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
element: HTMLElement | null;
|
|
22
|
+
mode?: 'only-height' | 'only-width';
|
|
23
|
+
style: CSSProperties | undefined;
|
|
24
|
+
},
|
|
25
|
+
...rest: unknown[]
|
|
26
|
+
) {
|
|
27
|
+
const slot = getSlot(rest);
|
|
28
|
+
const { styleHeight, styleWidth } = useMemo(
|
|
29
|
+
() => ({
|
|
30
|
+
styleHeight: parseNumericStyleValue(style?.height),
|
|
31
|
+
styleWidth: parseNumericStyleValue(style?.width),
|
|
32
|
+
}),
|
|
33
|
+
[style?.height, style?.width],
|
|
34
|
+
subSlot(slot, 'styles'),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const [state, setState] = useState<{
|
|
38
|
+
height: number | undefined;
|
|
39
|
+
width: number | undefined;
|
|
40
|
+
}>(
|
|
41
|
+
{
|
|
42
|
+
height: defaultHeight,
|
|
43
|
+
width: defaultWidth,
|
|
44
|
+
},
|
|
45
|
+
subSlot(slot, 'state'),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const disabled =
|
|
49
|
+
disabledProp ||
|
|
50
|
+
(mode === 'only-height' && styleHeight !== undefined) ||
|
|
51
|
+
(mode === 'only-width' && styleWidth !== undefined) ||
|
|
52
|
+
(styleHeight !== undefined && styleWidth !== undefined);
|
|
53
|
+
|
|
54
|
+
useIsomorphicLayoutEffect(
|
|
55
|
+
() => {
|
|
56
|
+
if (element === null || disabled) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const { contentRect, target } = entry;
|
|
63
|
+
if (element === target) {
|
|
64
|
+
setState((prevState) => {
|
|
65
|
+
if (
|
|
66
|
+
prevState.height === contentRect.height &&
|
|
67
|
+
prevState.width === contentRect.width
|
|
68
|
+
) {
|
|
69
|
+
return prevState;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
height: contentRect.height,
|
|
74
|
+
width: contentRect.width,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
resizeObserver.observe(element, { box });
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
resizeObserver?.unobserve(element);
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
[box, disabled, element, styleHeight, styleWidth],
|
|
87
|
+
subSlot(slot, 'observer'),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return useMemo(
|
|
91
|
+
() => ({
|
|
92
|
+
height: styleHeight ?? state.height,
|
|
93
|
+
width: styleWidth ?? state.width,
|
|
94
|
+
}),
|
|
95
|
+
[state, styleHeight, styleWidth],
|
|
96
|
+
subSlot(slot, 'result'),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'octane';
|
|
2
|
+
import { getSlot, subSlot } from '../internal.js';
|
|
3
|
+
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect.js';
|
|
4
|
+
|
|
5
|
+
// Forked from useEventCallback (usehooks-ts)
|
|
6
|
+
export function useStableCallback<Args extends unknown[], Return>(
|
|
7
|
+
fn: (...args: Args) => Return,
|
|
8
|
+
...rest: unknown[]
|
|
9
|
+
): (...args: Args) => Return;
|
|
10
|
+
export function useStableCallback<Args extends unknown[], Return>(
|
|
11
|
+
fn: ((...args: Args) => Return) | undefined,
|
|
12
|
+
...rest: unknown[]
|
|
13
|
+
): ((...args: Args) => Return) | undefined;
|
|
14
|
+
export function useStableCallback<Args extends unknown[], Return>(
|
|
15
|
+
fn: ((...args: Args) => Return) | undefined,
|
|
16
|
+
...rest: unknown[]
|
|
17
|
+
): ((...args: Args) => Return) | undefined {
|
|
18
|
+
const slot = getSlot(rest);
|
|
19
|
+
const ref = useRef<typeof fn>(
|
|
20
|
+
() => {
|
|
21
|
+
throw new Error('Cannot call an event handler while rendering.');
|
|
22
|
+
},
|
|
23
|
+
subSlot(slot, 'ref'),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
useIsomorphicLayoutEffect(
|
|
27
|
+
() => {
|
|
28
|
+
ref.current = fn;
|
|
29
|
+
},
|
|
30
|
+
[fn],
|
|
31
|
+
subSlot(slot, 'effect'),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return useCallback(
|
|
35
|
+
(...args: Args) => ref.current?.(...args),
|
|
36
|
+
[ref],
|
|
37
|
+
subSlot(slot, 'callback'),
|
|
38
|
+
) as (...args: Args) => Return;
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { Grid } from './components/grid/Grid.js';
|
|
2
|
+
export {
|
|
3
|
+
type CellComponentProps,
|
|
4
|
+
type GridImperativeAPI,
|
|
5
|
+
type GridProps,
|
|
6
|
+
} from './components/grid/types.js';
|
|
7
|
+
export { useGridCallbackRef } from './components/grid/useGridCallbackRef.js';
|
|
8
|
+
export { useGridRef } from './components/grid/useGridRef.js';
|
|
9
|
+
export { List } from './components/list/List.js';
|
|
10
|
+
export {
|
|
11
|
+
type DynamicRowHeight,
|
|
12
|
+
type ListImperativeAPI,
|
|
13
|
+
type ListProps,
|
|
14
|
+
type RowComponentProps,
|
|
15
|
+
} from './components/list/types.js';
|
|
16
|
+
export { useDynamicRowHeight } from './components/list/useDynamicRowHeight.js';
|
|
17
|
+
export { useListCallbackRef } from './components/list/useListCallbackRef.js';
|
|
18
|
+
export { useListRef } from './components/list/useListRef.js';
|
|
19
|
+
export { type Align } from './types.js';
|
|
20
|
+
export { getScrollbarSize } from './utils/getScrollbarSize.js';
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Octane appends a compiler-owned hook slot to public hook calls. Binding
|
|
2
|
+
// internals derive stable child slots so composed hooks and sibling
|
|
3
|
+
// virtualizers never share state.
|
|
4
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
5
|
+
const bareTagCache = new Map<string, symbol>();
|
|
6
|
+
|
|
7
|
+
export function getSlot(args: unknown[]): symbol | undefined {
|
|
8
|
+
const slot = args.at(-1);
|
|
9
|
+
return typeof slot === 'symbol' ? slot : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getPublicArgument(args: unknown[], index: number): unknown {
|
|
13
|
+
const publicLength = getSlot(args) === undefined ? args.length : args.length - 1;
|
|
14
|
+
return index < publicLength ? args[index] : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol {
|
|
18
|
+
if (slot === undefined) {
|
|
19
|
+
let bare = bareTagCache.get(tag);
|
|
20
|
+
if (bare === undefined) {
|
|
21
|
+
bare = Symbol.for(`@octanejs/window:${tag}`);
|
|
22
|
+
bareTagCache.set(tag, bare);
|
|
23
|
+
}
|
|
24
|
+
return bare;
|
|
25
|
+
}
|
|
26
|
+
let byTag = subSlotCache.get(slot);
|
|
27
|
+
if (byTag === undefined) {
|
|
28
|
+
byTag = new Map();
|
|
29
|
+
subSlotCache.set(slot, byTag);
|
|
30
|
+
}
|
|
31
|
+
let child = byTag.get(tag);
|
|
32
|
+
if (child === undefined) {
|
|
33
|
+
child = Symbol.for(`${slot.description ?? ''}:${tag}`);
|
|
34
|
+
byTag.set(tag, child);
|
|
35
|
+
}
|
|
36
|
+
return child;
|
|
37
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Direction } from '../core/types.js';
|
|
2
|
+
import { getRTLOffsetType } from './getRTLOffsetType.js';
|
|
3
|
+
|
|
4
|
+
export function adjustScrollOffsetForRtl({
|
|
5
|
+
containerElement,
|
|
6
|
+
direction,
|
|
7
|
+
isRtl,
|
|
8
|
+
scrollOffset,
|
|
9
|
+
}: {
|
|
10
|
+
containerElement: HTMLElement | null;
|
|
11
|
+
direction: Direction;
|
|
12
|
+
isRtl: boolean;
|
|
13
|
+
scrollOffset: number;
|
|
14
|
+
}) {
|
|
15
|
+
// TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements.
|
|
16
|
+
// This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left).
|
|
17
|
+
// So we need to determine which browser behavior we're dealing with, and mimic it.
|
|
18
|
+
if (direction === 'horizontal') {
|
|
19
|
+
if (isRtl) {
|
|
20
|
+
switch (getRTLOffsetType()) {
|
|
21
|
+
case 'negative': {
|
|
22
|
+
return -scrollOffset;
|
|
23
|
+
}
|
|
24
|
+
case 'positive-descending': {
|
|
25
|
+
if (containerElement) {
|
|
26
|
+
const { clientWidth, scrollLeft, scrollWidth } = containerElement;
|
|
27
|
+
return scrollWidth - clientWidth - scrollLeft;
|
|
28
|
+
}
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return scrollOffset;
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import { shallowCompare } from './shallowCompare.js';
|
|
3
|
+
|
|
4
|
+
// Custom comparison function for React.memo()
|
|
5
|
+
// It knows to compare individual style props and ignore the wrapper object.
|
|
6
|
+
// See https://react.dev/reference/react/memo#memo
|
|
7
|
+
export function arePropsEqual(
|
|
8
|
+
prevProps: { ariaAttributes: object; style: CSSProperties },
|
|
9
|
+
nextProps: { ariaAttributes: object; style: CSSProperties },
|
|
10
|
+
): boolean {
|
|
11
|
+
const { ariaAttributes: prevAriaAttributes, style: prevStyle, ...prevRest } = prevProps;
|
|
12
|
+
const { ariaAttributes: nextAriaAttributes, style: nextStyle, ...nextRest } = nextProps;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
shallowCompare(prevAriaAttributes, nextAriaAttributes) &&
|
|
16
|
+
shallowCompare(prevStyle, nextStyle) &&
|
|
17
|
+
shallowCompare(prevRest, nextRest)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function getContrastColor(hex: string) {
|
|
2
|
+
switch (hex.length) {
|
|
3
|
+
case 3: {
|
|
4
|
+
hex =
|
|
5
|
+
hex.charAt(0) +
|
|
6
|
+
hex.charAt(0) +
|
|
7
|
+
hex.charAt(1) +
|
|
8
|
+
hex.charAt(1) +
|
|
9
|
+
hex.charAt(2) +
|
|
10
|
+
hex.charAt(2);
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
case 4: {
|
|
14
|
+
hex =
|
|
15
|
+
hex.charAt(1) +
|
|
16
|
+
hex.charAt(1) +
|
|
17
|
+
hex.charAt(2) +
|
|
18
|
+
hex.charAt(2) +
|
|
19
|
+
hex.charAt(3) +
|
|
20
|
+
hex.charAt(3);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
case 6: {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
case 7: {
|
|
27
|
+
hex = hex.substring(1);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
default: {
|
|
31
|
+
throw Error(`Invalid hex value: "${hex}"`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
36
|
+
if (!rgb) {
|
|
37
|
+
throw Error(`Invalid hex value: "${hex}"`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const red = parseInt(rgb[1], 16);
|
|
41
|
+
const green = parseInt(rgb[2], 16);
|
|
42
|
+
const blue = parseInt(rgb[3], 16);
|
|
43
|
+
|
|
44
|
+
const brightness = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
45
|
+
|
|
46
|
+
return brightness >= 128 ? 'black' : 'white';
|
|
47
|
+
}
|