@octanejs/stick-to-bottom 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 +21 -0
- package/README.md +21 -0
- package/UPSTREAM.md +45 -0
- package/package.json +54 -0
- package/src/StickToBottom.tsrx +173 -0
- package/src/index.ts +2 -0
- package/src/useStickToBottom.ts +708 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 - present StackBlitz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @octanejs/stick-to-bottom
|
|
2
|
+
|
|
3
|
+
Octane binding for [`use-stick-to-bottom@1.1.6`](https://github.com/stackblitz/use-stick-to-bottom).
|
|
4
|
+
|
|
5
|
+
```tsrx
|
|
6
|
+
import { StickToBottom } from '@octanejs/stick-to-bottom';
|
|
7
|
+
|
|
8
|
+
export function Transcript(props: { messages: string[] }) @{
|
|
9
|
+
<StickToBottom>
|
|
10
|
+
<StickToBottom.Content>
|
|
11
|
+
@for (const message of props.messages; key message) {
|
|
12
|
+
<p>{message}</p>
|
|
13
|
+
}
|
|
14
|
+
</StickToBottom.Content>
|
|
15
|
+
</StickToBottom>
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Nested TSRX children render normally. Function children remain render props and receive the stick-to-bottom context.
|
|
20
|
+
|
|
21
|
+
See [UPSTREAM.md](./UPSTREAM.md).
|
package/UPSTREAM.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Upstream provenance
|
|
2
|
+
|
|
3
|
+
`@octanejs/stick-to-bottom` is pinned to `use-stick-to-bottom@1.1.6`.
|
|
4
|
+
|
|
5
|
+
- repository: https://github.com/stackblitz/use-stick-to-bottom
|
|
6
|
+
- tag: `1.1.6`
|
|
7
|
+
- tag commit: `8d6a19a0ca6ab632830588073e6a29312a06a088` (npm tarball had no `gitHead`; SHA is the tagged commit)
|
|
8
|
+
- advertised range: `1.1.x`
|
|
9
|
+
- license: MIT
|
|
10
|
+
- npm integrity: `sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog==`
|
|
11
|
+
- npm shasum: `c35e7f9bcb1ee3da0c059c448fb9a61bdf01fe6b`
|
|
12
|
+
|
|
13
|
+
## Source boundary
|
|
14
|
+
|
|
15
|
+
- `upstream/src/` is the canonical tagged source (`useStickToBottom.ts`, `StickToBottom.tsx`, `index.ts`), LICENSE, README, CHANGELOG, and tsconfig.
|
|
16
|
+
- `upstream/npm/` is the published tarball (compiled `dist/`).
|
|
17
|
+
- Octane `src/` mirrors `upstream/src/`.
|
|
18
|
+
|
|
19
|
+
Vendored evidence is development-only and excluded from package `files`.
|
|
20
|
+
|
|
21
|
+
## Export crosswalk
|
|
22
|
+
|
|
23
|
+
| Upstream export | Octane status | Evidence / divergence |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| `useStickToBottom` | Ported | `src/useStickToBottom.ts`; upstream logic retained with structural ref types and explicit hook-slot forwarding; `tests/stick-to-bottom.test.ts` |
|
|
26
|
+
| `StickToBottom` | Ported | `src/StickToBottom.tsrx` |
|
|
27
|
+
| `StickToBottom.Content` | Ported | Assigned as `StickToBottom.Content` |
|
|
28
|
+
| `useStickToBottomContext` | Ported | throws outside provider; `tests/stick-to-bottom.test.ts` |
|
|
29
|
+
| `StickToBottomState` / options / instance types | Ported | `src/useStickToBottom.ts` |
|
|
30
|
+
|
|
31
|
+
## Upstream test disposition
|
|
32
|
+
|
|
33
|
+
| Upstream artifact | Disposition |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| package scripts (`dev`, `build`, `lint`; no `test`) | Confirmed at pin: the repository has no unit test suite |
|
|
36
|
+
| Demo / playground | Out of scope |
|
|
37
|
+
|
|
38
|
+
Octane behavioral coverage is `tests/stick-to-bottom.test.ts`. It is Octane-only because upstream ships no tests.
|
|
39
|
+
|
|
40
|
+
## Intentional divergences
|
|
41
|
+
|
|
42
|
+
- React ref types are structural.
|
|
43
|
+
- The plain TypeScript hook accepts and forwards a compiler-injected trailing
|
|
44
|
+
slot; when called without options, a symbol in the options position is
|
|
45
|
+
treated as empty options.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/stick-to-bottom",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Octane binding for use-stick-to-bottom 1.1.6.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.22.2"
|
|
9
|
+
},
|
|
10
|
+
"octane": {
|
|
11
|
+
"hookSlots": {
|
|
12
|
+
"manual": [
|
|
13
|
+
"src/useStickToBottom.ts"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Dominic Gannaway",
|
|
19
|
+
"email": "dg@domgan.com"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
27
|
+
"directory": "packages/stick-to-bottom"
|
|
28
|
+
},
|
|
29
|
+
"main": "./src/index.ts",
|
|
30
|
+
"module": "./src/index.ts",
|
|
31
|
+
"types": "./src/index.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"UPSTREAM.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"exports": {
|
|
39
|
+
".": "./src/index.ts",
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"octane": "0.1.45"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"vitest": "^4.1.10",
|
|
47
|
+
"@octanejs/testing-library": "0.1.42",
|
|
48
|
+
"octane": "0.1.45"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "vitest run --root ../.. --config vitest.config.js --project stick-to-bottom",
|
|
52
|
+
"typecheck": "tsrx-tsc --noEmit"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/*!---------------------------------------------------------------------------------------------
|
|
2
|
+
* Copyright (c) StackBlitz. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type OctaneNode,
|
|
8
|
+
createContext,
|
|
9
|
+
isChildrenBlock,
|
|
10
|
+
useCallback,
|
|
11
|
+
useContext,
|
|
12
|
+
useEffect,
|
|
13
|
+
useImperativeHandle,
|
|
14
|
+
useLayoutEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
} from 'octane';
|
|
18
|
+
import {
|
|
19
|
+
type GetTargetScrollTop,
|
|
20
|
+
type StickToBottomInstance,
|
|
21
|
+
type StickToBottomOptions,
|
|
22
|
+
type StickToBottomState,
|
|
23
|
+
type ScrollToBottom,
|
|
24
|
+
type StopScroll,
|
|
25
|
+
useStickToBottom,
|
|
26
|
+
} from './useStickToBottom';
|
|
27
|
+
|
|
28
|
+
type DivProps = import('octane/jsx-runtime').Octane.JSX.IntrinsicElements['div'];
|
|
29
|
+
|
|
30
|
+
export interface StickToBottomContext {
|
|
31
|
+
contentRef: StickToBottomInstance['contentRef'];
|
|
32
|
+
scrollRef: StickToBottomInstance['scrollRef'];
|
|
33
|
+
scrollToBottom: ScrollToBottom;
|
|
34
|
+
stopScroll: StopScroll;
|
|
35
|
+
isAtBottom: boolean;
|
|
36
|
+
escapedFromLock: boolean;
|
|
37
|
+
get targetScrollTop(): GetTargetScrollTop | null;
|
|
38
|
+
set targetScrollTop(targetScrollTop: GetTargetScrollTop | null);
|
|
39
|
+
state: StickToBottomState;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const StickToBottomContext = createContext<StickToBottomContext | null>(null);
|
|
43
|
+
|
|
44
|
+
type StickToBottomContextRef =
|
|
45
|
+
{ current: StickToBottomContext | null } | ((value: StickToBottomContext | null) => void);
|
|
46
|
+
|
|
47
|
+
export interface StickToBottomProps extends Omit<DivProps, 'children'>, StickToBottomOptions {
|
|
48
|
+
contextRef?: StickToBottomContextRef;
|
|
49
|
+
instance?: StickToBottomInstance;
|
|
50
|
+
children: ((context: StickToBottomContext) => OctaneNode) | OctaneNode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const useIsomorphicLayoutEffect =
|
|
54
|
+
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
55
|
+
|
|
56
|
+
export function StickToBottom({
|
|
57
|
+
instance,
|
|
58
|
+
children,
|
|
59
|
+
resize,
|
|
60
|
+
initial,
|
|
61
|
+
mass,
|
|
62
|
+
damping,
|
|
63
|
+
stiffness,
|
|
64
|
+
targetScrollTop: currentTargetScrollTop,
|
|
65
|
+
contextRef,
|
|
66
|
+
...props
|
|
67
|
+
}: StickToBottomProps) {
|
|
68
|
+
const customTargetScrollTop = useRef<GetTargetScrollTop | null>(null);
|
|
69
|
+
|
|
70
|
+
const targetScrollTop = useCallback<GetTargetScrollTop>(
|
|
71
|
+
function getTargetScrollTop(target, elements) {
|
|
72
|
+
const get = context?.targetScrollTop ?? currentTargetScrollTop;
|
|
73
|
+
return get?.(target, elements) ?? target;
|
|
74
|
+
},
|
|
75
|
+
[currentTargetScrollTop],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const defaultInstance = useStickToBottom({
|
|
79
|
+
mass,
|
|
80
|
+
damping,
|
|
81
|
+
stiffness,
|
|
82
|
+
resize,
|
|
83
|
+
initial,
|
|
84
|
+
targetScrollTop,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { scrollRef, contentRef, scrollToBottom, stopScroll, isAtBottom, escapedFromLock, state } =
|
|
88
|
+
instance ?? defaultInstance;
|
|
89
|
+
|
|
90
|
+
const context = useMemo(function createContextValue(): StickToBottomContext {
|
|
91
|
+
return {
|
|
92
|
+
scrollToBottom,
|
|
93
|
+
stopScroll,
|
|
94
|
+
scrollRef,
|
|
95
|
+
isAtBottom,
|
|
96
|
+
escapedFromLock,
|
|
97
|
+
contentRef,
|
|
98
|
+
state,
|
|
99
|
+
get targetScrollTop() {
|
|
100
|
+
return customTargetScrollTop.current;
|
|
101
|
+
},
|
|
102
|
+
set targetScrollTop(next: GetTargetScrollTop | null) {
|
|
103
|
+
customTargetScrollTop.current = next;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}, [
|
|
107
|
+
scrollToBottom,
|
|
108
|
+
isAtBottom,
|
|
109
|
+
contentRef,
|
|
110
|
+
scrollRef,
|
|
111
|
+
stopScroll,
|
|
112
|
+
escapedFromLock,
|
|
113
|
+
state,
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
useImperativeHandle(contextRef, function getContext() {
|
|
117
|
+
return context;
|
|
118
|
+
}, [context]);
|
|
119
|
+
|
|
120
|
+
useIsomorphicLayoutEffect(function ensureOverflow() {
|
|
121
|
+
if (!scrollRef.current) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (getComputedStyle(scrollRef.current).overflow === 'visible') {
|
|
126
|
+
scrollRef.current.style.overflow = 'auto';
|
|
127
|
+
}
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
return <StickToBottomContext.Provider value={context}>
|
|
131
|
+
<div {...props}>
|
|
132
|
+
{typeof children === 'function' && !isChildrenBlock(children) ? children(context) : children}
|
|
133
|
+
</div>
|
|
134
|
+
</StickToBottomContext.Provider>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface StickToBottomContentProps extends Omit<DivProps, 'children'> {
|
|
138
|
+
children: ((context: StickToBottomContext) => OctaneNode) | OctaneNode;
|
|
139
|
+
scrollClassName?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function StickToBottomContent({
|
|
143
|
+
children,
|
|
144
|
+
scrollClassName,
|
|
145
|
+
...props
|
|
146
|
+
}: StickToBottomContentProps) {
|
|
147
|
+
const context = useStickToBottomContext();
|
|
148
|
+
|
|
149
|
+
return <div
|
|
150
|
+
ref={context.scrollRef}
|
|
151
|
+
style={{
|
|
152
|
+
height: '100%',
|
|
153
|
+
width: '100%',
|
|
154
|
+
scrollbarGutter: 'stable both-edges',
|
|
155
|
+
}}
|
|
156
|
+
className={scrollClassName}
|
|
157
|
+
>
|
|
158
|
+
<div {...props} ref={context.contentRef}>
|
|
159
|
+
{typeof children === 'function' && !isChildrenBlock(children) ? children(context) : children}
|
|
160
|
+
</div>
|
|
161
|
+
</div>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
StickToBottom.Content = StickToBottomContent;
|
|
165
|
+
|
|
166
|
+
export function useStickToBottomContext(): StickToBottomContext {
|
|
167
|
+
const context = useContext(StickToBottomContext);
|
|
168
|
+
if (!context) {
|
|
169
|
+
throw new Error('use-stick-to-bottom component context must be used within a StickToBottom component');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return context;
|
|
173
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/*!---------------------------------------------------------------------------------------------
|
|
2
|
+
* Copyright (c) StackBlitz. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useMemo, useRef, useState } from 'octane';
|
|
7
|
+
|
|
8
|
+
type DependencyList = readonly unknown[];
|
|
9
|
+
type RefCallback<T> = (instance: T | null) => void;
|
|
10
|
+
type MutableRefObject<T> = { current: T };
|
|
11
|
+
|
|
12
|
+
export interface StickToBottomState {
|
|
13
|
+
scrollTop: number;
|
|
14
|
+
lastScrollTop?: number;
|
|
15
|
+
ignoreScrollToTop?: number;
|
|
16
|
+
targetScrollTop: number;
|
|
17
|
+
calculatedTargetScrollTop: number;
|
|
18
|
+
scrollDifference: number;
|
|
19
|
+
resizeDifference: number;
|
|
20
|
+
|
|
21
|
+
animation?: {
|
|
22
|
+
behavior: 'instant' | Required<SpringAnimation>;
|
|
23
|
+
ignoreEscapes: boolean;
|
|
24
|
+
promise: Promise<boolean>;
|
|
25
|
+
};
|
|
26
|
+
lastTick?: number;
|
|
27
|
+
velocity: number;
|
|
28
|
+
accumulated: number;
|
|
29
|
+
|
|
30
|
+
escapedFromLock: boolean;
|
|
31
|
+
isAtBottom: boolean;
|
|
32
|
+
isNearBottom: boolean;
|
|
33
|
+
|
|
34
|
+
resizeObserver?: ResizeObserver;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_SPRING_ANIMATION = {
|
|
38
|
+
/**
|
|
39
|
+
* A value from 0 to 1, on how much to damp the animation.
|
|
40
|
+
* 0 means no damping, 1 means full damping.
|
|
41
|
+
*
|
|
42
|
+
* @default 0.7
|
|
43
|
+
*/
|
|
44
|
+
damping: 0.7,
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The stiffness of how fast/slow the animation gets up to speed.
|
|
48
|
+
*
|
|
49
|
+
* @default 0.05
|
|
50
|
+
*/
|
|
51
|
+
stiffness: 0.05,
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The inertial mass associated with the animation.
|
|
55
|
+
* Higher numbers make the animation slower.
|
|
56
|
+
*
|
|
57
|
+
* @default 1.25
|
|
58
|
+
*/
|
|
59
|
+
mass: 1.25,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface SpringAnimation extends Partial<typeof DEFAULT_SPRING_ANIMATION> {}
|
|
63
|
+
|
|
64
|
+
export type Animation = ScrollBehavior | SpringAnimation;
|
|
65
|
+
|
|
66
|
+
export interface ScrollElements {
|
|
67
|
+
scrollElement: HTMLElement;
|
|
68
|
+
contentElement: HTMLElement;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type GetTargetScrollTop = (targetScrollTop: number, context: ScrollElements) => number;
|
|
72
|
+
|
|
73
|
+
export interface StickToBottomOptions extends SpringAnimation {
|
|
74
|
+
resize?: Animation;
|
|
75
|
+
initial?: Animation | boolean;
|
|
76
|
+
targetScrollTop?: GetTargetScrollTop;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ScrollToBottomOptions =
|
|
80
|
+
| ScrollBehavior
|
|
81
|
+
| {
|
|
82
|
+
animation?: Animation;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether to wait for any existing scrolls to finish before
|
|
86
|
+
* performing this one. Or if a millisecond is passed,
|
|
87
|
+
* it will wait for that duration before performing the scroll.
|
|
88
|
+
*
|
|
89
|
+
* @default false
|
|
90
|
+
*/
|
|
91
|
+
wait?: boolean | number;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether to prevent the user from escaping the scroll,
|
|
95
|
+
* by scrolling up with their mouse.
|
|
96
|
+
*/
|
|
97
|
+
ignoreEscapes?: boolean;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Only scroll to the bottom if we're already at the bottom.
|
|
101
|
+
*
|
|
102
|
+
* @default false
|
|
103
|
+
*/
|
|
104
|
+
preserveScrollPosition?: boolean;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The extra duration in ms that this scroll event should persist for.
|
|
108
|
+
* (in addition to the time that it takes to get to the bottom)
|
|
109
|
+
*
|
|
110
|
+
* Not to be confused with the duration of the animation -
|
|
111
|
+
* for that you should adjust the animation option.
|
|
112
|
+
*
|
|
113
|
+
* @default 0
|
|
114
|
+
*/
|
|
115
|
+
duration?: number | Promise<void>;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type ScrollToBottom = (scrollOptions?: ScrollToBottomOptions) => Promise<boolean> | boolean;
|
|
119
|
+
export type StopScroll = () => void;
|
|
120
|
+
|
|
121
|
+
const STICK_TO_BOTTOM_OFFSET_PX = 70;
|
|
122
|
+
const SIXTY_FPS_INTERVAL_MS = 1000 / 60;
|
|
123
|
+
const RETAIN_ANIMATION_DURATION_MS = 350;
|
|
124
|
+
|
|
125
|
+
let mouseDown = false;
|
|
126
|
+
|
|
127
|
+
globalThis.document?.addEventListener('mousedown', () => {
|
|
128
|
+
mouseDown = true;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
globalThis.document?.addEventListener('mouseup', () => {
|
|
132
|
+
mouseDown = false;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
globalThis.document?.addEventListener('click', () => {
|
|
136
|
+
mouseDown = false;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export function useStickToBottom(options?: StickToBottomOptions): StickToBottomInstance;
|
|
140
|
+
export function useStickToBottom(
|
|
141
|
+
options: StickToBottomOptions | symbol = {},
|
|
142
|
+
...rest: [slot?: symbol]
|
|
143
|
+
): StickToBottomInstance {
|
|
144
|
+
const slot = typeof options === 'symbol' ? options : rest[0];
|
|
145
|
+
options = typeof options === 'symbol' ? {} : options;
|
|
146
|
+
const [escapedFromLock, updateEscapedFromLock] = useState(
|
|
147
|
+
false,
|
|
148
|
+
subSlot(slot, 'escaped-from-lock'),
|
|
149
|
+
);
|
|
150
|
+
const [isAtBottom, updateIsAtBottom] = useState(
|
|
151
|
+
options.initial !== false,
|
|
152
|
+
subSlot(slot, 'at-bottom'),
|
|
153
|
+
);
|
|
154
|
+
const [isNearBottom, setIsNearBottom] = useState(false, subSlot(slot, 'near-bottom'));
|
|
155
|
+
|
|
156
|
+
const optionsRef = useRef<StickToBottomOptions>(null!, subSlot(slot, 'options'));
|
|
157
|
+
optionsRef.current = options;
|
|
158
|
+
|
|
159
|
+
const isSelecting = useCallback(
|
|
160
|
+
() => {
|
|
161
|
+
if (!mouseDown) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const selection = window.getSelection();
|
|
166
|
+
if (!selection || !selection.rangeCount) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const range = selection.getRangeAt(0);
|
|
171
|
+
return (
|
|
172
|
+
range.commonAncestorContainer.contains(scrollRef.current) ||
|
|
173
|
+
scrollRef.current?.contains(range.commonAncestorContainer)
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
[],
|
|
177
|
+
subSlot(slot, 'is-selecting'),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const setIsAtBottom = useCallback(
|
|
181
|
+
(isAtBottom: boolean) => {
|
|
182
|
+
state.isAtBottom = isAtBottom;
|
|
183
|
+
updateIsAtBottom(isAtBottom);
|
|
184
|
+
},
|
|
185
|
+
[],
|
|
186
|
+
subSlot(slot, 'set-at-bottom'),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const setEscapedFromLock = useCallback(
|
|
190
|
+
(escapedFromLock: boolean) => {
|
|
191
|
+
state.escapedFromLock = escapedFromLock;
|
|
192
|
+
updateEscapedFromLock(escapedFromLock);
|
|
193
|
+
},
|
|
194
|
+
[],
|
|
195
|
+
subSlot(slot, 'set-escaped-from-lock'),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: not needed
|
|
199
|
+
const state = useMemo<StickToBottomState>(
|
|
200
|
+
() => {
|
|
201
|
+
let lastCalculation: { targetScrollTop: number; calculatedScrollTop: number } | undefined;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
escapedFromLock,
|
|
205
|
+
isAtBottom,
|
|
206
|
+
resizeDifference: 0,
|
|
207
|
+
accumulated: 0,
|
|
208
|
+
velocity: 0,
|
|
209
|
+
listeners: new Set(),
|
|
210
|
+
|
|
211
|
+
get scrollTop() {
|
|
212
|
+
return scrollRef.current?.scrollTop ?? 0;
|
|
213
|
+
},
|
|
214
|
+
set scrollTop(scrollTop: number) {
|
|
215
|
+
if (scrollRef.current) {
|
|
216
|
+
// Override CSS scroll-behavior so programmatic scrollTop
|
|
217
|
+
// assignments take effect immediately and aren't intercepted
|
|
218
|
+
// by the browser's smooth scrolling.
|
|
219
|
+
const { scrollBehavior } = getComputedStyle(scrollRef.current);
|
|
220
|
+
|
|
221
|
+
if (scrollBehavior !== 'auto') {
|
|
222
|
+
scrollRef.current.style.scrollBehavior = 'auto';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
scrollRef.current.scrollTop = scrollTop;
|
|
226
|
+
state.ignoreScrollToTop = scrollRef.current.scrollTop;
|
|
227
|
+
|
|
228
|
+
if (scrollBehavior !== 'auto') {
|
|
229
|
+
scrollRef.current.style.scrollBehavior = scrollBehavior;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
get targetScrollTop() {
|
|
235
|
+
if (!scrollRef.current || !contentRef.current) {
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return scrollRef.current.scrollHeight - 1 - scrollRef.current.clientHeight;
|
|
240
|
+
},
|
|
241
|
+
get calculatedTargetScrollTop() {
|
|
242
|
+
if (!scrollRef.current || !contentRef.current) {
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { targetScrollTop } = this;
|
|
247
|
+
|
|
248
|
+
if (!options.targetScrollTop) {
|
|
249
|
+
return targetScrollTop;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (lastCalculation?.targetScrollTop === targetScrollTop) {
|
|
253
|
+
return lastCalculation.calculatedScrollTop;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const calculatedScrollTop = Math.max(
|
|
257
|
+
Math.min(
|
|
258
|
+
options.targetScrollTop(targetScrollTop, {
|
|
259
|
+
scrollElement: scrollRef.current,
|
|
260
|
+
contentElement: contentRef.current,
|
|
261
|
+
}),
|
|
262
|
+
targetScrollTop,
|
|
263
|
+
),
|
|
264
|
+
0,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
lastCalculation = { targetScrollTop, calculatedScrollTop };
|
|
268
|
+
|
|
269
|
+
requestAnimationFrame(() => {
|
|
270
|
+
lastCalculation = undefined;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return calculatedScrollTop;
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
get scrollDifference() {
|
|
277
|
+
return this.calculatedTargetScrollTop - this.scrollTop;
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
get isNearBottom() {
|
|
281
|
+
return this.scrollDifference <= STICK_TO_BOTTOM_OFFSET_PX;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
[],
|
|
286
|
+
subSlot(slot, 'state'),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const scrollToBottom = useCallback<ScrollToBottom>(
|
|
290
|
+
(scrollOptions = {}) => {
|
|
291
|
+
if (typeof scrollOptions === 'string') {
|
|
292
|
+
scrollOptions = { animation: scrollOptions };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!scrollOptions.preserveScrollPosition) {
|
|
296
|
+
setIsAtBottom(true);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const waitElapsed = Date.now() + (Number(scrollOptions.wait) || 0);
|
|
300
|
+
const behavior = mergeAnimations(optionsRef.current, scrollOptions.animation);
|
|
301
|
+
const { ignoreEscapes = false } = scrollOptions;
|
|
302
|
+
|
|
303
|
+
let durationElapsed: number;
|
|
304
|
+
let startTarget = state.calculatedTargetScrollTop;
|
|
305
|
+
|
|
306
|
+
if (scrollOptions.duration instanceof Promise) {
|
|
307
|
+
scrollOptions.duration.finally(() => {
|
|
308
|
+
durationElapsed = Date.now();
|
|
309
|
+
});
|
|
310
|
+
} else {
|
|
311
|
+
durationElapsed = waitElapsed + (scrollOptions.duration ?? 0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const next = async (): Promise<boolean> => {
|
|
315
|
+
const promise = new Promise(requestAnimationFrame).then(() => {
|
|
316
|
+
if (!state.isAtBottom) {
|
|
317
|
+
state.animation = undefined;
|
|
318
|
+
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const { scrollTop } = state;
|
|
323
|
+
const tick = performance.now();
|
|
324
|
+
const tickDelta = (tick - (state.lastTick ?? tick)) / SIXTY_FPS_INTERVAL_MS;
|
|
325
|
+
state.animation ||= { behavior, promise, ignoreEscapes };
|
|
326
|
+
|
|
327
|
+
if (state.animation.behavior === behavior) {
|
|
328
|
+
state.lastTick = tick;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (isSelecting()) {
|
|
332
|
+
return next();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (waitElapsed > Date.now()) {
|
|
336
|
+
return next();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (scrollTop < Math.min(startTarget, state.calculatedTargetScrollTop)) {
|
|
340
|
+
if (state.animation?.behavior === behavior) {
|
|
341
|
+
if (behavior === 'instant') {
|
|
342
|
+
state.scrollTop = state.calculatedTargetScrollTop;
|
|
343
|
+
return next();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
state.velocity =
|
|
347
|
+
(behavior.damping * state.velocity + behavior.stiffness * state.scrollDifference) /
|
|
348
|
+
behavior.mass;
|
|
349
|
+
state.accumulated += state.velocity * tickDelta;
|
|
350
|
+
state.scrollTop += state.accumulated;
|
|
351
|
+
|
|
352
|
+
if (state.scrollTop !== scrollTop) {
|
|
353
|
+
state.accumulated = 0;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return next();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (durationElapsed > Date.now()) {
|
|
361
|
+
startTarget = state.calculatedTargetScrollTop;
|
|
362
|
+
|
|
363
|
+
return next();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
state.animation = undefined;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* If we're still below the target, then queue
|
|
370
|
+
* up another scroll to the bottom with the last
|
|
371
|
+
* requested animation.
|
|
372
|
+
*/
|
|
373
|
+
if (state.scrollTop < state.calculatedTargetScrollTop) {
|
|
374
|
+
return scrollToBottom({
|
|
375
|
+
animation: mergeAnimations(optionsRef.current, optionsRef.current.resize),
|
|
376
|
+
ignoreEscapes,
|
|
377
|
+
duration: Math.max(0, durationElapsed - Date.now()) || undefined,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return state.isAtBottom;
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return promise.then((isAtBottom) => {
|
|
385
|
+
requestAnimationFrame(() => {
|
|
386
|
+
if (!state.animation) {
|
|
387
|
+
state.lastTick = undefined;
|
|
388
|
+
state.velocity = 0;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return isAtBottom;
|
|
393
|
+
});
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
if (scrollOptions.wait !== true) {
|
|
397
|
+
state.animation = undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (state.animation?.behavior === behavior) {
|
|
401
|
+
return state.animation.promise;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return next();
|
|
405
|
+
},
|
|
406
|
+
[setIsAtBottom, isSelecting, state],
|
|
407
|
+
subSlot(slot, 'scroll-to-bottom'),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const stopScroll = useCallback(
|
|
411
|
+
(): void => {
|
|
412
|
+
setEscapedFromLock(true);
|
|
413
|
+
setIsAtBottom(false);
|
|
414
|
+
},
|
|
415
|
+
[setEscapedFromLock, setIsAtBottom],
|
|
416
|
+
subSlot(slot, 'stop-scroll'),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const handleScroll = useCallback(
|
|
420
|
+
({ target }: Event) => {
|
|
421
|
+
if (target !== scrollRef.current) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const { scrollTop, ignoreScrollToTop } = state;
|
|
426
|
+
let { lastScrollTop = scrollTop } = state;
|
|
427
|
+
|
|
428
|
+
state.lastScrollTop = scrollTop;
|
|
429
|
+
state.ignoreScrollToTop = undefined;
|
|
430
|
+
|
|
431
|
+
if (ignoreScrollToTop && ignoreScrollToTop > scrollTop) {
|
|
432
|
+
/**
|
|
433
|
+
* When the user scrolls up while the animation plays, the `scrollTop` may
|
|
434
|
+
* not come in separate events; if this happens, to make sure `isScrollingUp`
|
|
435
|
+
* is correct, set the lastScrollTop to the ignored event.
|
|
436
|
+
*/
|
|
437
|
+
lastScrollTop = ignoreScrollToTop;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
setIsNearBottom(state.isNearBottom);
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Scroll events may come before a ResizeObserver event,
|
|
444
|
+
* so in order to ignore resize events correctly we use a
|
|
445
|
+
* timeout.
|
|
446
|
+
*
|
|
447
|
+
* @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228
|
|
448
|
+
*/
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
/**
|
|
451
|
+
* When theres a resize difference ignore the resize event.
|
|
452
|
+
*/
|
|
453
|
+
if (state.resizeDifference || scrollTop === ignoreScrollToTop) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (isSelecting()) {
|
|
458
|
+
setEscapedFromLock(true);
|
|
459
|
+
setIsAtBottom(false);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const isScrollingDown = scrollTop > lastScrollTop;
|
|
464
|
+
const isScrollingUp = scrollTop < lastScrollTop;
|
|
465
|
+
|
|
466
|
+
if (state.animation?.ignoreEscapes) {
|
|
467
|
+
state.scrollTop = lastScrollTop;
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (isScrollingUp) {
|
|
472
|
+
setEscapedFromLock(true);
|
|
473
|
+
setIsAtBottom(false);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (isScrollingDown) {
|
|
477
|
+
setEscapedFromLock(false);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!state.escapedFromLock && state.isNearBottom) {
|
|
481
|
+
setIsAtBottom(true);
|
|
482
|
+
}
|
|
483
|
+
}, 1);
|
|
484
|
+
},
|
|
485
|
+
[setEscapedFromLock, setIsAtBottom, isSelecting, state],
|
|
486
|
+
subSlot(slot, 'handle-scroll'),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const handleWheel = useCallback(
|
|
490
|
+
({ target, deltaY }: WheelEvent) => {
|
|
491
|
+
let element = target as HTMLElement;
|
|
492
|
+
|
|
493
|
+
while (!['scroll', 'auto'].includes(getComputedStyle(element).overflow)) {
|
|
494
|
+
if (!element.parentElement) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
element = element.parentElement;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The browser may cancel the scrolling from the mouse wheel
|
|
503
|
+
* if we update it from the animation in meantime.
|
|
504
|
+
* To prevent this, always escape when the wheel is scrolled up.
|
|
505
|
+
*/
|
|
506
|
+
if (
|
|
507
|
+
element === scrollRef.current &&
|
|
508
|
+
deltaY < 0 &&
|
|
509
|
+
scrollRef.current.scrollHeight > scrollRef.current.clientHeight &&
|
|
510
|
+
!state.animation?.ignoreEscapes
|
|
511
|
+
) {
|
|
512
|
+
setEscapedFromLock(true);
|
|
513
|
+
setIsAtBottom(false);
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
[setEscapedFromLock, setIsAtBottom, state],
|
|
517
|
+
subSlot(slot, 'handle-wheel'),
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const scrollRef = useRefCallback(
|
|
521
|
+
(scroll) => {
|
|
522
|
+
scrollRef.current?.removeEventListener('scroll', handleScroll);
|
|
523
|
+
scrollRef.current?.removeEventListener('wheel', handleWheel);
|
|
524
|
+
scroll?.addEventListener('scroll', handleScroll, { passive: true });
|
|
525
|
+
scroll?.addEventListener('wheel', handleWheel, { passive: true });
|
|
526
|
+
},
|
|
527
|
+
[],
|
|
528
|
+
subSlot(slot, 'scroll-ref'),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const contentRef = useRefCallback(
|
|
532
|
+
(content) => {
|
|
533
|
+
state.resizeObserver?.disconnect();
|
|
534
|
+
state.resizeObserver = undefined;
|
|
535
|
+
|
|
536
|
+
if (!content) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let previousHeight: number | undefined;
|
|
541
|
+
|
|
542
|
+
state.resizeObserver = new ResizeObserver(([entry]) => {
|
|
543
|
+
const { height } = entry.contentRect;
|
|
544
|
+
const difference = height - (previousHeight ?? height);
|
|
545
|
+
|
|
546
|
+
state.resizeDifference = difference;
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Sometimes the browser can overscroll past the target,
|
|
550
|
+
* so check for this and adjust appropriately.
|
|
551
|
+
*/
|
|
552
|
+
if (state.scrollTop > state.targetScrollTop) {
|
|
553
|
+
state.scrollTop = state.targetScrollTop;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
setIsNearBottom(state.isNearBottom);
|
|
557
|
+
|
|
558
|
+
if (difference >= 0) {
|
|
559
|
+
/**
|
|
560
|
+
* If it's a positive resize, scroll to the bottom when
|
|
561
|
+
* we're already at the bottom.
|
|
562
|
+
*/
|
|
563
|
+
const animation = mergeAnimations(
|
|
564
|
+
optionsRef.current,
|
|
565
|
+
previousHeight ? optionsRef.current.resize : optionsRef.current.initial,
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
scrollToBottom({
|
|
569
|
+
animation,
|
|
570
|
+
wait: true,
|
|
571
|
+
preserveScrollPosition: true,
|
|
572
|
+
duration: animation === 'instant' ? undefined : RETAIN_ANIMATION_DURATION_MS,
|
|
573
|
+
});
|
|
574
|
+
} else {
|
|
575
|
+
/**
|
|
576
|
+
* Else if it's a negative resize, check if we're near the bottom
|
|
577
|
+
* if we are want to un-escape from the lock, because the resize
|
|
578
|
+
* could have caused the container to be at the bottom.
|
|
579
|
+
*/
|
|
580
|
+
if (state.isNearBottom) {
|
|
581
|
+
setEscapedFromLock(false);
|
|
582
|
+
setIsAtBottom(true);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
previousHeight = height;
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Reset the resize difference after the scroll event
|
|
590
|
+
* has fired. Requires a rAF to wait for the scroll event,
|
|
591
|
+
* and a setTimeout to wait for the other timeout we have in
|
|
592
|
+
* resizeObserver in case the scroll event happens after the
|
|
593
|
+
* resize event.
|
|
594
|
+
*/
|
|
595
|
+
requestAnimationFrame(() => {
|
|
596
|
+
setTimeout(() => {
|
|
597
|
+
if (state.resizeDifference === difference) {
|
|
598
|
+
state.resizeDifference = 0;
|
|
599
|
+
}
|
|
600
|
+
}, 1);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
state.resizeObserver?.observe(content);
|
|
605
|
+
},
|
|
606
|
+
[],
|
|
607
|
+
subSlot(slot, 'content-ref'),
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
contentRef,
|
|
612
|
+
scrollRef,
|
|
613
|
+
scrollToBottom,
|
|
614
|
+
stopScroll,
|
|
615
|
+
isAtBottom: isAtBottom || isNearBottom,
|
|
616
|
+
isNearBottom,
|
|
617
|
+
escapedFromLock,
|
|
618
|
+
state,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export interface StickToBottomInstance {
|
|
623
|
+
contentRef: MutableRefObject<HTMLElement | null> & RefCallback<HTMLElement>;
|
|
624
|
+
scrollRef: MutableRefObject<HTMLElement | null> & RefCallback<HTMLElement>;
|
|
625
|
+
scrollToBottom: ScrollToBottom;
|
|
626
|
+
stopScroll: StopScroll;
|
|
627
|
+
isAtBottom: boolean;
|
|
628
|
+
isNearBottom: boolean;
|
|
629
|
+
escapedFromLock: boolean;
|
|
630
|
+
state: StickToBottomState;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function useRefCallback<T extends (ref: HTMLElement | null) => any>(
|
|
634
|
+
callback: T,
|
|
635
|
+
deps: DependencyList,
|
|
636
|
+
slot: symbol,
|
|
637
|
+
) {
|
|
638
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: not needed
|
|
639
|
+
const result = useCallback(
|
|
640
|
+
(ref: HTMLElement | null) => {
|
|
641
|
+
result.current = ref;
|
|
642
|
+
return callback(ref);
|
|
643
|
+
},
|
|
644
|
+
deps as unknown[],
|
|
645
|
+
slot,
|
|
646
|
+
) as any as MutableRefObject<HTMLElement | null> & RefCallback<HTMLElement>;
|
|
647
|
+
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const animationCache = new Map<string, Readonly<Required<SpringAnimation>>>();
|
|
652
|
+
|
|
653
|
+
function mergeAnimations(...animations: (Animation | boolean | undefined)[]) {
|
|
654
|
+
const result = { ...DEFAULT_SPRING_ANIMATION };
|
|
655
|
+
let instant = false;
|
|
656
|
+
|
|
657
|
+
for (const animation of animations) {
|
|
658
|
+
if (animation === 'instant') {
|
|
659
|
+
instant = true;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (typeof animation !== 'object') {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
instant = false;
|
|
668
|
+
|
|
669
|
+
result.damping = animation.damping ?? result.damping;
|
|
670
|
+
result.stiffness = animation.stiffness ?? result.stiffness;
|
|
671
|
+
result.mass = animation.mass ?? result.mass;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const key = JSON.stringify(result);
|
|
675
|
+
|
|
676
|
+
if (!animationCache.has(key)) {
|
|
677
|
+
animationCache.set(key, Object.freeze(result));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return instant ? 'instant' : animationCache.get(key)!;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
684
|
+
const bareSlotCache = new Map<string, symbol>();
|
|
685
|
+
|
|
686
|
+
function subSlot(slot: symbol | undefined, tag: string): symbol {
|
|
687
|
+
if (slot === undefined) {
|
|
688
|
+
let bare = bareSlotCache.get(tag);
|
|
689
|
+
if (bare === undefined) {
|
|
690
|
+
bare = Symbol.for(`@octanejs/stick-to-bottom:${tag}`);
|
|
691
|
+
bareSlotCache.set(tag, bare);
|
|
692
|
+
}
|
|
693
|
+
return bare;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
let children = subSlotCache.get(slot);
|
|
697
|
+
if (children === undefined) {
|
|
698
|
+
children = new Map();
|
|
699
|
+
subSlotCache.set(slot, children);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let child = children.get(tag);
|
|
703
|
+
if (child === undefined) {
|
|
704
|
+
child = Symbol.for(`${slot.description ?? ''}:stick-to-bottom:${tag}`);
|
|
705
|
+
children.set(tag, child);
|
|
706
|
+
}
|
|
707
|
+
return child;
|
|
708
|
+
}
|