use-stick-to-bottom-extended 1.2.0
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.txt +21 -0
- package/README.md +145 -0
- package/dist/StickToBottom.d.ts +35 -0
- package/dist/StickToBottom.js +81 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/tsconfig.app.tsbuildinfo +1 -0
- package/dist/tsconfig.demo.tsbuildinfo +1 -0
- package/dist/useStickToBottom.d.ts +106 -0
- package/dist/useStickToBottom.js +425 -0
- package/package.json +65 -0
package/LICENSE.txt
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,145 @@
|
|
|
1
|
+
# `use-stick-to-bottom-extended`
|
|
2
|
+
|
|
3
|
+
> Designed with AI chat bots in mind
|
|
4
|
+
|
|
5
|
+
This package is a fork of the original [`use-stick-to-bottom`](https://github.com/stackblitz-labs/use-stick-to-bottom). The original implementation and copyright belong to StackBlitz/Sam Denty. This extended fork is maintained by [duz52](https://github.com/duz52/).
|
|
6
|
+
|
|
7
|
+
This fork adds `resize={false}` / `resize: false`, which disables ResizeObserver-driven automatic scrolling after initial layout. Use it when a chat should scroll once after sending a new message only if the user was already at the bottom, without continuing to stick to the bottom while the window is being resized.
|
|
8
|
+
|
|
9
|
+
A lightweight **zero-dependency** React hook + Component that automatically sticks to the bottom of container and smoothly animates the content to keep it's visual position on screen whilst new content is being added.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Does not require [`overflow-anchor`](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor) browser-level CSS support which Safari does not support.
|
|
14
|
+
- Can be connected up to any existing component using a hook with refs. Or simply use the provided component, which handles the refs for you plus provides context - so child components can check `isAtBottom` & programmatically scroll to the bottom.
|
|
15
|
+
- Uses the modern, yet well-supported, [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) API to detect when content resizes.
|
|
16
|
+
- Supports content shrinking without losing stickiness - not just getting taller.
|
|
17
|
+
- Correctly handles [Scroll Anchoring](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor/Guide_to_scroll_anchoring). This is where when content above the viewport resizes, it doesn't cause the content currently displayed in viewport to jump up or down.
|
|
18
|
+
- Allows the user to cancel the stickiness at any time by scrolling up.
|
|
19
|
+
- Clever logic distinguishes the user scrolling from the custom animation scroll events (without doing any debouncing which could cause some events to be missed).
|
|
20
|
+
- Mobile devices work well with this logic too.
|
|
21
|
+
- Uses a custom implemented smooth scrolling algorithm, featuring velocity-based spring animations (with configurable parameters).
|
|
22
|
+
- Other libraries use easing functions with durations instead, but these doesn't work well when you want to stream in new content with variable sizing - which is common for AI chatbot use cases.
|
|
23
|
+
- `scrollToBottom` returns a `Promise<boolean>` which will resolve to `true` as soon as the scroll was successful, or `false` if the scroll was cancelled.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install github:duz52/use-stick-to-bottom-extended
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### `<StickToBottom>` Component
|
|
34
|
+
|
|
35
|
+
```jsx
|
|
36
|
+
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom-extended';
|
|
37
|
+
|
|
38
|
+
function Chat() {
|
|
39
|
+
return (
|
|
40
|
+
<StickToBottom className="h-[50vh] relative" resize="smooth" initial="smooth">
|
|
41
|
+
<StickToBottom.Content className="flex flex-col gap-4">
|
|
42
|
+
{messages.map((message) => (
|
|
43
|
+
<Message key={message.id} message={message} />
|
|
44
|
+
))}
|
|
45
|
+
</StickToBottom.Content>
|
|
46
|
+
|
|
47
|
+
<ScrollToBottom />
|
|
48
|
+
|
|
49
|
+
{/* This component uses `useStickToBottomContext` to scroll to bottom when the user enters a message */}
|
|
50
|
+
<ChatBox />
|
|
51
|
+
</StickToBottom>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ScrollToBottom() {
|
|
56
|
+
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
!isAtBottom && (
|
|
60
|
+
<button
|
|
61
|
+
className="absolute i-ph-arrow-circle-down-fill text-4xl rounded-lg left-[50%] translate-x-[-50%] bottom-0"
|
|
62
|
+
onClick={() => scrollToBottom()}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### One-shot send scroll without resize stickiness
|
|
70
|
+
|
|
71
|
+
Set `resize={false}` to disable automatic scrolling from content resize events after the initial layout. Capture whether the user was at the bottom before appending the message, then scroll once after the message is rendered.
|
|
72
|
+
|
|
73
|
+
```jsx
|
|
74
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
75
|
+
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom-extended';
|
|
76
|
+
|
|
77
|
+
function Chat() {
|
|
78
|
+
const [messages, setMessages] = useState([]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<StickToBottom className="h-[50vh] relative" resize={false} initial="instant">
|
|
82
|
+
<ChatContent messages={messages} setMessages={setMessages} />
|
|
83
|
+
</StickToBottom>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ChatContent({ messages, setMessages }) {
|
|
88
|
+
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
89
|
+
const shouldScrollAfterSend = useRef(false);
|
|
90
|
+
|
|
91
|
+
const sendMessage = (text) => {
|
|
92
|
+
shouldScrollAfterSend.current = isAtBottom;
|
|
93
|
+
setMessages((current) => [...current, { id: crypto.randomUUID(), text }]);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
if (!shouldScrollAfterSend.current) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
shouldScrollAfterSend.current = false;
|
|
102
|
+
scrollToBottom({ animation: 'instant' });
|
|
103
|
+
}, [messages.length, scrollToBottom]);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<StickToBottom.Content className="flex flex-col gap-4">
|
|
108
|
+
{messages.map((message) => (
|
|
109
|
+
<Message key={message.id} message={message} />
|
|
110
|
+
))}
|
|
111
|
+
</StickToBottom.Content>
|
|
112
|
+
|
|
113
|
+
<ChatBox onSend={sendMessage} />
|
|
114
|
+
</>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `useStickToBottom` Hook
|
|
120
|
+
|
|
121
|
+
```jsx
|
|
122
|
+
import { useStickToBottom } from 'use-stick-to-bottom-extended';
|
|
123
|
+
|
|
124
|
+
function Component() {
|
|
125
|
+
const { scrollRef, contentRef } = useStickToBottom({ resize: false });
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div style={{ overflow: 'auto' }} ref={scrollRef}>
|
|
129
|
+
<div ref={contentRef}>
|
|
130
|
+
{messages.map((message) => (
|
|
131
|
+
<Message key={message.id} message={message} />
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Extended API
|
|
140
|
+
|
|
141
|
+
### `resize: false`
|
|
142
|
+
|
|
143
|
+
The original package accepts `resize` as an animation setting, such as `"smooth"`, `"instant"`, or a spring animation object. This fork also accepts `false`.
|
|
144
|
+
|
|
145
|
+
When `resize` is `false`, the hook still observes content size changes so scroll state stays accurate, but it does not automatically call `scrollToBottom` for positive content resizes after the first layout. That lets applications implement explicit, one-shot scroll behavior on send while avoiding continued bottom locking during window resize.
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import * as React from "react";
|
|
6
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
7
|
+
import { type GetTargetScrollTop, type ScrollToBottom, type StickToBottomInstance, type StickToBottomOptions, type StickToBottomState, type StopScroll } from "./useStickToBottom.js";
|
|
8
|
+
export interface StickToBottomContext {
|
|
9
|
+
contentRef: React.MutableRefObject<HTMLElement | null> & React.RefCallback<HTMLElement>;
|
|
10
|
+
scrollRef: React.MutableRefObject<HTMLElement | null> & React.RefCallback<HTMLElement>;
|
|
11
|
+
scrollToBottom: ScrollToBottom;
|
|
12
|
+
stopScroll: StopScroll;
|
|
13
|
+
isAtBottom: boolean;
|
|
14
|
+
escapedFromLock: boolean;
|
|
15
|
+
get targetScrollTop(): GetTargetScrollTop | null;
|
|
16
|
+
set targetScrollTop(targetScrollTop: GetTargetScrollTop | null);
|
|
17
|
+
state: StickToBottomState;
|
|
18
|
+
}
|
|
19
|
+
export interface StickToBottomProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children">, StickToBottomOptions {
|
|
20
|
+
contextRef?: React.Ref<StickToBottomContext>;
|
|
21
|
+
instance?: StickToBottomInstance;
|
|
22
|
+
children: ((context: StickToBottomContext) => ReactNode) | ReactNode;
|
|
23
|
+
}
|
|
24
|
+
export declare function StickToBottom({ instance, children, resize, initial, mass, damping, stiffness, targetScrollTop: currentTargetScrollTop, contextRef, ...props }: StickToBottomProps): ReactElement;
|
|
25
|
+
export declare namespace StickToBottom {
|
|
26
|
+
interface ContentProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
27
|
+
children: ((context: StickToBottomContext) => ReactNode) | ReactNode;
|
|
28
|
+
scrollClassName?: string;
|
|
29
|
+
}
|
|
30
|
+
function Content({ children, scrollClassName, ...props }: ContentProps): ReactElement;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Use this hook inside a <StickToBottom> component to gain access to whether the component is at the bottom of the scrollable area.
|
|
34
|
+
*/
|
|
35
|
+
export declare function useStickToBottomContext(): StickToBottomContext;
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
import * as React from "react";
|
|
6
|
+
import { createContext, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, } from "react";
|
|
7
|
+
import { useStickToBottom, } from "./useStickToBottom.js";
|
|
8
|
+
const StickToBottomContext = createContext(null);
|
|
9
|
+
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
10
|
+
export function StickToBottom({ instance, children, resize, initial, mass, damping, stiffness, targetScrollTop: currentTargetScrollTop, contextRef, ...props }) {
|
|
11
|
+
const customTargetScrollTop = useRef(null);
|
|
12
|
+
const targetScrollTop = React.useCallback((target, elements) => {
|
|
13
|
+
const get = context?.targetScrollTop ?? currentTargetScrollTop;
|
|
14
|
+
return get?.(target, elements) ?? target;
|
|
15
|
+
}, [currentTargetScrollTop]);
|
|
16
|
+
const defaultInstance = useStickToBottom({
|
|
17
|
+
mass,
|
|
18
|
+
damping,
|
|
19
|
+
stiffness,
|
|
20
|
+
resize,
|
|
21
|
+
initial,
|
|
22
|
+
targetScrollTop,
|
|
23
|
+
});
|
|
24
|
+
const { scrollRef, contentRef, scrollToBottom, stopScroll, isAtBottom, escapedFromLock, state, } = instance ?? defaultInstance;
|
|
25
|
+
const context = useMemo(() => ({
|
|
26
|
+
scrollToBottom,
|
|
27
|
+
stopScroll,
|
|
28
|
+
scrollRef,
|
|
29
|
+
isAtBottom,
|
|
30
|
+
escapedFromLock,
|
|
31
|
+
contentRef,
|
|
32
|
+
state,
|
|
33
|
+
get targetScrollTop() {
|
|
34
|
+
return customTargetScrollTop.current;
|
|
35
|
+
},
|
|
36
|
+
set targetScrollTop(targetScrollTop) {
|
|
37
|
+
customTargetScrollTop.current = targetScrollTop;
|
|
38
|
+
},
|
|
39
|
+
}), [
|
|
40
|
+
scrollToBottom,
|
|
41
|
+
isAtBottom,
|
|
42
|
+
contentRef,
|
|
43
|
+
scrollRef,
|
|
44
|
+
stopScroll,
|
|
45
|
+
escapedFromLock,
|
|
46
|
+
state,
|
|
47
|
+
]);
|
|
48
|
+
useImperativeHandle(contextRef, () => context, [context]);
|
|
49
|
+
useIsomorphicLayoutEffect(() => {
|
|
50
|
+
if (!scrollRef.current) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (getComputedStyle(scrollRef.current).overflow === "visible") {
|
|
54
|
+
scrollRef.current.style.overflow = "auto";
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
57
|
+
return (React.createElement(StickToBottomContext.Provider, { value: context },
|
|
58
|
+
React.createElement("div", { ...props }, typeof children === "function" ? children(context) : children)));
|
|
59
|
+
}
|
|
60
|
+
(function (StickToBottom) {
|
|
61
|
+
function Content({ children, scrollClassName, ...props }) {
|
|
62
|
+
const context = useStickToBottomContext();
|
|
63
|
+
return (React.createElement("div", { ref: context.scrollRef, style: {
|
|
64
|
+
height: "100%",
|
|
65
|
+
width: "100%",
|
|
66
|
+
scrollbarGutter: "stable both-edges",
|
|
67
|
+
}, className: scrollClassName },
|
|
68
|
+
React.createElement("div", { ...props, ref: context.contentRef }, typeof children === "function" ? children(context) : children)));
|
|
69
|
+
}
|
|
70
|
+
StickToBottom.Content = Content;
|
|
71
|
+
})(StickToBottom || (StickToBottom = {}));
|
|
72
|
+
/**
|
|
73
|
+
* Use this hook inside a <StickToBottom> component to gain access to whether the component is at the bottom of the scrollable area.
|
|
74
|
+
*/
|
|
75
|
+
export function useStickToBottomContext() {
|
|
76
|
+
const context = useContext(StickToBottomContext);
|
|
77
|
+
if (!context) {
|
|
78
|
+
throw new Error("use-stick-to-bottom-extended component context must be used within a StickToBottom component");
|
|
79
|
+
}
|
|
80
|
+
return context;
|
|
81
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["../src/StickToBottom.tsx","../src/index.ts","../src/useStickToBottom.ts"],"version":"5.8.2"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["../demo/Demo.tsx","../demo/index.tsx","../demo/useFakeMessages.tsx"],"version":"5.8.2"}
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
export interface StickToBottomState {
|
|
6
|
+
scrollTop: number;
|
|
7
|
+
lastScrollTop?: number;
|
|
8
|
+
ignoreScrollToTop?: number;
|
|
9
|
+
targetScrollTop: number;
|
|
10
|
+
calculatedTargetScrollTop: number;
|
|
11
|
+
scrollDifference: number;
|
|
12
|
+
resizeDifference: number;
|
|
13
|
+
animation?: {
|
|
14
|
+
behavior: "instant" | Required<SpringAnimation>;
|
|
15
|
+
ignoreEscapes: boolean;
|
|
16
|
+
promise: Promise<boolean>;
|
|
17
|
+
};
|
|
18
|
+
lastTick?: number;
|
|
19
|
+
velocity: number;
|
|
20
|
+
accumulated: number;
|
|
21
|
+
escapedFromLock: boolean;
|
|
22
|
+
isAtBottom: boolean;
|
|
23
|
+
isNearBottom: boolean;
|
|
24
|
+
resizeObserver?: ResizeObserver;
|
|
25
|
+
}
|
|
26
|
+
declare const DEFAULT_SPRING_ANIMATION: {
|
|
27
|
+
/**
|
|
28
|
+
* A value from 0 to 1, on how much to damp the animation.
|
|
29
|
+
* 0 means no damping, 1 means full damping.
|
|
30
|
+
*
|
|
31
|
+
* @default 0.7
|
|
32
|
+
*/
|
|
33
|
+
damping: number;
|
|
34
|
+
/**
|
|
35
|
+
* The stiffness of how fast/slow the animation gets up to speed.
|
|
36
|
+
*
|
|
37
|
+
* @default 0.05
|
|
38
|
+
*/
|
|
39
|
+
stiffness: number;
|
|
40
|
+
/**
|
|
41
|
+
* The inertial mass associated with the animation.
|
|
42
|
+
* Higher numbers make the animation slower.
|
|
43
|
+
*
|
|
44
|
+
* @default 1.25
|
|
45
|
+
*/
|
|
46
|
+
mass: number;
|
|
47
|
+
};
|
|
48
|
+
export interface SpringAnimation extends Partial<typeof DEFAULT_SPRING_ANIMATION> {
|
|
49
|
+
}
|
|
50
|
+
export type Animation = ScrollBehavior | SpringAnimation;
|
|
51
|
+
export interface ScrollElements {
|
|
52
|
+
scrollElement: HTMLElement;
|
|
53
|
+
contentElement: HTMLElement;
|
|
54
|
+
}
|
|
55
|
+
export type GetTargetScrollTop = (targetScrollTop: number, context: ScrollElements) => number;
|
|
56
|
+
export interface StickToBottomOptions extends SpringAnimation {
|
|
57
|
+
resize?: Animation | false;
|
|
58
|
+
initial?: Animation | boolean;
|
|
59
|
+
targetScrollTop?: GetTargetScrollTop;
|
|
60
|
+
}
|
|
61
|
+
export type ScrollToBottomOptions = ScrollBehavior | {
|
|
62
|
+
animation?: Animation;
|
|
63
|
+
/**
|
|
64
|
+
* Whether to wait for any existing scrolls to finish before
|
|
65
|
+
* performing this one. Or if a millisecond is passed,
|
|
66
|
+
* it will wait for that duration before performing the scroll.
|
|
67
|
+
*
|
|
68
|
+
* @default false
|
|
69
|
+
*/
|
|
70
|
+
wait?: boolean | number;
|
|
71
|
+
/**
|
|
72
|
+
* Whether to prevent the user from escaping the scroll,
|
|
73
|
+
* by scrolling up with their mouse.
|
|
74
|
+
*/
|
|
75
|
+
ignoreEscapes?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Only scroll to the bottom if we're already at the bottom.
|
|
78
|
+
*
|
|
79
|
+
* @default false
|
|
80
|
+
*/
|
|
81
|
+
preserveScrollPosition?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The extra duration in ms that this scroll event should persist for.
|
|
84
|
+
* (in addition to the time that it takes to get to the bottom)
|
|
85
|
+
*
|
|
86
|
+
* Not to be confused with the duration of the animation -
|
|
87
|
+
* for that you should adjust the animation option.
|
|
88
|
+
*
|
|
89
|
+
* @default 0
|
|
90
|
+
*/
|
|
91
|
+
duration?: number | Promise<void>;
|
|
92
|
+
};
|
|
93
|
+
export type ScrollToBottom = (scrollOptions?: ScrollToBottomOptions) => Promise<boolean> | boolean;
|
|
94
|
+
export type StopScroll = () => void;
|
|
95
|
+
export declare const useStickToBottom: (options?: StickToBottomOptions) => StickToBottomInstance;
|
|
96
|
+
export interface StickToBottomInstance {
|
|
97
|
+
contentRef: React.MutableRefObject<HTMLElement | null> & React.RefCallback<HTMLElement>;
|
|
98
|
+
scrollRef: React.MutableRefObject<HTMLElement | null> & React.RefCallback<HTMLElement>;
|
|
99
|
+
scrollToBottom: ScrollToBottom;
|
|
100
|
+
stopScroll: StopScroll;
|
|
101
|
+
isAtBottom: boolean;
|
|
102
|
+
isNearBottom: boolean;
|
|
103
|
+
escapedFromLock: boolean;
|
|
104
|
+
state: StickToBottomState;
|
|
105
|
+
}
|
|
106
|
+
export {};
|
|
@@ -0,0 +1,425 @@
|
|
|
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
|
+
import { useCallback, useMemo, useRef, useState, } from "react";
|
|
6
|
+
const DEFAULT_SPRING_ANIMATION = {
|
|
7
|
+
/**
|
|
8
|
+
* A value from 0 to 1, on how much to damp the animation.
|
|
9
|
+
* 0 means no damping, 1 means full damping.
|
|
10
|
+
*
|
|
11
|
+
* @default 0.7
|
|
12
|
+
*/
|
|
13
|
+
damping: 0.7,
|
|
14
|
+
/**
|
|
15
|
+
* The stiffness of how fast/slow the animation gets up to speed.
|
|
16
|
+
*
|
|
17
|
+
* @default 0.05
|
|
18
|
+
*/
|
|
19
|
+
stiffness: 0.05,
|
|
20
|
+
/**
|
|
21
|
+
* The inertial mass associated with the animation.
|
|
22
|
+
* Higher numbers make the animation slower.
|
|
23
|
+
*
|
|
24
|
+
* @default 1.25
|
|
25
|
+
*/
|
|
26
|
+
mass: 1.25,
|
|
27
|
+
};
|
|
28
|
+
const STICK_TO_BOTTOM_OFFSET_PX = 70;
|
|
29
|
+
const SIXTY_FPS_INTERVAL_MS = 1000 / 60;
|
|
30
|
+
const RETAIN_ANIMATION_DURATION_MS = 350;
|
|
31
|
+
let mouseDown = false;
|
|
32
|
+
globalThis.document?.addEventListener("mousedown", () => {
|
|
33
|
+
mouseDown = true;
|
|
34
|
+
});
|
|
35
|
+
globalThis.document?.addEventListener("mouseup", () => {
|
|
36
|
+
mouseDown = false;
|
|
37
|
+
});
|
|
38
|
+
globalThis.document?.addEventListener("click", () => {
|
|
39
|
+
mouseDown = false;
|
|
40
|
+
});
|
|
41
|
+
export const useStickToBottom = (options = {}) => {
|
|
42
|
+
const [escapedFromLock, updateEscapedFromLock] = useState(false);
|
|
43
|
+
const [isAtBottom, updateIsAtBottom] = useState(options.initial !== false);
|
|
44
|
+
const [isNearBottom, setIsNearBottom] = useState(false);
|
|
45
|
+
const optionsRef = useRef(null);
|
|
46
|
+
optionsRef.current = options;
|
|
47
|
+
const isSelecting = useCallback(() => {
|
|
48
|
+
if (!mouseDown) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const selection = window.getSelection();
|
|
52
|
+
if (!selection || !selection.rangeCount) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const range = selection.getRangeAt(0);
|
|
56
|
+
return (range.commonAncestorContainer.contains(scrollRef.current) ||
|
|
57
|
+
scrollRef.current?.contains(range.commonAncestorContainer));
|
|
58
|
+
}, []);
|
|
59
|
+
const setIsAtBottom = useCallback((isAtBottom) => {
|
|
60
|
+
state.isAtBottom = isAtBottom;
|
|
61
|
+
updateIsAtBottom(isAtBottom);
|
|
62
|
+
}, []);
|
|
63
|
+
const setEscapedFromLock = useCallback((escapedFromLock) => {
|
|
64
|
+
state.escapedFromLock = escapedFromLock;
|
|
65
|
+
updateEscapedFromLock(escapedFromLock);
|
|
66
|
+
}, []);
|
|
67
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: not needed
|
|
68
|
+
const state = useMemo(() => {
|
|
69
|
+
let lastCalculation;
|
|
70
|
+
return {
|
|
71
|
+
escapedFromLock,
|
|
72
|
+
isAtBottom,
|
|
73
|
+
resizeDifference: 0,
|
|
74
|
+
accumulated: 0,
|
|
75
|
+
velocity: 0,
|
|
76
|
+
listeners: new Set(),
|
|
77
|
+
get scrollTop() {
|
|
78
|
+
return scrollRef.current?.scrollTop ?? 0;
|
|
79
|
+
},
|
|
80
|
+
set scrollTop(scrollTop) {
|
|
81
|
+
if (scrollRef.current) {
|
|
82
|
+
// Override CSS scroll-behavior so programmatic scrollTop
|
|
83
|
+
// assignments take effect immediately and aren't intercepted
|
|
84
|
+
// by the browser's smooth scrolling.
|
|
85
|
+
const { scrollBehavior } = getComputedStyle(scrollRef.current);
|
|
86
|
+
if (scrollBehavior !== "auto") {
|
|
87
|
+
scrollRef.current.style.scrollBehavior = "auto";
|
|
88
|
+
}
|
|
89
|
+
scrollRef.current.scrollTop = scrollTop;
|
|
90
|
+
state.ignoreScrollToTop = scrollRef.current.scrollTop;
|
|
91
|
+
if (scrollBehavior !== "auto") {
|
|
92
|
+
scrollRef.current.style.scrollBehavior = scrollBehavior;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
get targetScrollTop() {
|
|
97
|
+
if (!scrollRef.current || !contentRef.current) {
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
return (scrollRef.current.scrollHeight - 1 - scrollRef.current.clientHeight);
|
|
101
|
+
},
|
|
102
|
+
get calculatedTargetScrollTop() {
|
|
103
|
+
if (!scrollRef.current || !contentRef.current) {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const { targetScrollTop } = this;
|
|
107
|
+
if (!options.targetScrollTop) {
|
|
108
|
+
return targetScrollTop;
|
|
109
|
+
}
|
|
110
|
+
if (lastCalculation?.targetScrollTop === targetScrollTop) {
|
|
111
|
+
return lastCalculation.calculatedScrollTop;
|
|
112
|
+
}
|
|
113
|
+
const calculatedScrollTop = Math.max(Math.min(options.targetScrollTop(targetScrollTop, {
|
|
114
|
+
scrollElement: scrollRef.current,
|
|
115
|
+
contentElement: contentRef.current,
|
|
116
|
+
}), targetScrollTop), 0);
|
|
117
|
+
lastCalculation = { targetScrollTop, calculatedScrollTop };
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
lastCalculation = undefined;
|
|
120
|
+
});
|
|
121
|
+
return calculatedScrollTop;
|
|
122
|
+
},
|
|
123
|
+
get scrollDifference() {
|
|
124
|
+
return this.calculatedTargetScrollTop - this.scrollTop;
|
|
125
|
+
},
|
|
126
|
+
get isNearBottom() {
|
|
127
|
+
return this.scrollDifference <= STICK_TO_BOTTOM_OFFSET_PX;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}, []);
|
|
131
|
+
const scrollToBottom = useCallback((scrollOptions = {}) => {
|
|
132
|
+
if (typeof scrollOptions === "string") {
|
|
133
|
+
scrollOptions = { animation: scrollOptions };
|
|
134
|
+
}
|
|
135
|
+
if (!scrollOptions.preserveScrollPosition) {
|
|
136
|
+
setIsAtBottom(true);
|
|
137
|
+
}
|
|
138
|
+
const waitElapsed = Date.now() + (Number(scrollOptions.wait) || 0);
|
|
139
|
+
const behavior = mergeAnimations(optionsRef.current, scrollOptions.animation);
|
|
140
|
+
const { ignoreEscapes = false } = scrollOptions;
|
|
141
|
+
let durationElapsed;
|
|
142
|
+
let startTarget = state.calculatedTargetScrollTop;
|
|
143
|
+
if (scrollOptions.duration instanceof Promise) {
|
|
144
|
+
scrollOptions.duration.finally(() => {
|
|
145
|
+
durationElapsed = Date.now();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
durationElapsed = waitElapsed + (scrollOptions.duration ?? 0);
|
|
150
|
+
}
|
|
151
|
+
const next = async () => {
|
|
152
|
+
const promise = new Promise(requestAnimationFrame).then(() => {
|
|
153
|
+
if (!state.isAtBottom) {
|
|
154
|
+
state.animation = undefined;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const { scrollTop } = state;
|
|
158
|
+
const tick = performance.now();
|
|
159
|
+
const tickDelta = (tick - (state.lastTick ?? tick)) / SIXTY_FPS_INTERVAL_MS;
|
|
160
|
+
state.animation || (state.animation = { behavior, promise, ignoreEscapes });
|
|
161
|
+
if (state.animation.behavior === behavior) {
|
|
162
|
+
state.lastTick = tick;
|
|
163
|
+
}
|
|
164
|
+
if (isSelecting()) {
|
|
165
|
+
return next();
|
|
166
|
+
}
|
|
167
|
+
if (waitElapsed > Date.now()) {
|
|
168
|
+
return next();
|
|
169
|
+
}
|
|
170
|
+
if (scrollTop < Math.min(startTarget, state.calculatedTargetScrollTop)) {
|
|
171
|
+
if (state.animation?.behavior === behavior) {
|
|
172
|
+
if (behavior === "instant") {
|
|
173
|
+
state.scrollTop = state.calculatedTargetScrollTop;
|
|
174
|
+
return next();
|
|
175
|
+
}
|
|
176
|
+
state.velocity =
|
|
177
|
+
(behavior.damping * state.velocity +
|
|
178
|
+
behavior.stiffness * state.scrollDifference) /
|
|
179
|
+
behavior.mass;
|
|
180
|
+
state.accumulated += state.velocity * tickDelta;
|
|
181
|
+
state.scrollTop += state.accumulated;
|
|
182
|
+
if (state.scrollTop !== scrollTop) {
|
|
183
|
+
state.accumulated = 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return next();
|
|
187
|
+
}
|
|
188
|
+
if (durationElapsed > Date.now()) {
|
|
189
|
+
startTarget = state.calculatedTargetScrollTop;
|
|
190
|
+
return next();
|
|
191
|
+
}
|
|
192
|
+
state.animation = undefined;
|
|
193
|
+
/**
|
|
194
|
+
* If we're still below the target, then queue
|
|
195
|
+
* up another scroll to the bottom with the last
|
|
196
|
+
* requested animation.
|
|
197
|
+
*/
|
|
198
|
+
if (state.scrollTop < state.calculatedTargetScrollTop) {
|
|
199
|
+
if (optionsRef.current.resize === false) {
|
|
200
|
+
return state.isNearBottom;
|
|
201
|
+
}
|
|
202
|
+
return scrollToBottom({
|
|
203
|
+
animation: mergeAnimations(optionsRef.current, optionsRef.current.resize),
|
|
204
|
+
ignoreEscapes,
|
|
205
|
+
duration: Math.max(0, durationElapsed - Date.now()) || undefined,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return state.isAtBottom;
|
|
209
|
+
});
|
|
210
|
+
return promise.then((isAtBottom) => {
|
|
211
|
+
requestAnimationFrame(() => {
|
|
212
|
+
if (!state.animation) {
|
|
213
|
+
state.lastTick = undefined;
|
|
214
|
+
state.velocity = 0;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return isAtBottom;
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
if (scrollOptions.wait !== true) {
|
|
221
|
+
state.animation = undefined;
|
|
222
|
+
}
|
|
223
|
+
if (state.animation?.behavior === behavior) {
|
|
224
|
+
return state.animation.promise;
|
|
225
|
+
}
|
|
226
|
+
return next();
|
|
227
|
+
}, [setIsAtBottom, isSelecting, state]);
|
|
228
|
+
const stopScroll = useCallback(() => {
|
|
229
|
+
setEscapedFromLock(true);
|
|
230
|
+
setIsAtBottom(false);
|
|
231
|
+
}, [setEscapedFromLock, setIsAtBottom]);
|
|
232
|
+
const handleScroll = useCallback(({ target }) => {
|
|
233
|
+
if (target !== scrollRef.current) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const { scrollTop, ignoreScrollToTop } = state;
|
|
237
|
+
let { lastScrollTop = scrollTop } = state;
|
|
238
|
+
state.lastScrollTop = scrollTop;
|
|
239
|
+
state.ignoreScrollToTop = undefined;
|
|
240
|
+
if (ignoreScrollToTop && ignoreScrollToTop > scrollTop) {
|
|
241
|
+
/**
|
|
242
|
+
* When the user scrolls up while the animation plays, the `scrollTop` may
|
|
243
|
+
* not come in separate events; if this happens, to make sure `isScrollingUp`
|
|
244
|
+
* is correct, set the lastScrollTop to the ignored event.
|
|
245
|
+
*/
|
|
246
|
+
lastScrollTop = ignoreScrollToTop;
|
|
247
|
+
}
|
|
248
|
+
setIsNearBottom(state.isNearBottom);
|
|
249
|
+
/**
|
|
250
|
+
* Scroll events may come before a ResizeObserver event,
|
|
251
|
+
* so in order to ignore resize events correctly we use a
|
|
252
|
+
* timeout.
|
|
253
|
+
*
|
|
254
|
+
* @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228
|
|
255
|
+
*/
|
|
256
|
+
setTimeout(() => {
|
|
257
|
+
/**
|
|
258
|
+
* When theres a resize difference ignore the resize event.
|
|
259
|
+
*/
|
|
260
|
+
if (state.resizeDifference || scrollTop === ignoreScrollToTop) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (isSelecting()) {
|
|
264
|
+
setEscapedFromLock(true);
|
|
265
|
+
setIsAtBottom(false);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const isScrollingDown = scrollTop > lastScrollTop;
|
|
269
|
+
const isScrollingUp = scrollTop < lastScrollTop;
|
|
270
|
+
if (state.animation?.ignoreEscapes) {
|
|
271
|
+
state.scrollTop = lastScrollTop;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (isScrollingUp) {
|
|
275
|
+
setEscapedFromLock(true);
|
|
276
|
+
setIsAtBottom(false);
|
|
277
|
+
}
|
|
278
|
+
if (isScrollingDown) {
|
|
279
|
+
setEscapedFromLock(false);
|
|
280
|
+
}
|
|
281
|
+
if (!state.escapedFromLock && state.isNearBottom) {
|
|
282
|
+
setIsAtBottom(true);
|
|
283
|
+
}
|
|
284
|
+
}, 1);
|
|
285
|
+
}, [setEscapedFromLock, setIsAtBottom, isSelecting, state]);
|
|
286
|
+
const handleWheel = useCallback(({ target, deltaY }) => {
|
|
287
|
+
let element = target;
|
|
288
|
+
while (!["scroll", "auto"].includes(getComputedStyle(element).overflow)) {
|
|
289
|
+
if (!element.parentElement) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
element = element.parentElement;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The browser may cancel the scrolling from the mouse wheel
|
|
296
|
+
* if we update it from the animation in meantime.
|
|
297
|
+
* To prevent this, always escape when the wheel is scrolled up.
|
|
298
|
+
*/
|
|
299
|
+
if (element === scrollRef.current &&
|
|
300
|
+
deltaY < 0 &&
|
|
301
|
+
scrollRef.current.scrollHeight > scrollRef.current.clientHeight &&
|
|
302
|
+
!state.animation?.ignoreEscapes) {
|
|
303
|
+
setEscapedFromLock(true);
|
|
304
|
+
setIsAtBottom(false);
|
|
305
|
+
}
|
|
306
|
+
}, [setEscapedFromLock, setIsAtBottom, state]);
|
|
307
|
+
const scrollRef = useRefCallback((scroll) => {
|
|
308
|
+
scrollRef.current?.removeEventListener("scroll", handleScroll);
|
|
309
|
+
scrollRef.current?.removeEventListener("wheel", handleWheel);
|
|
310
|
+
scroll?.addEventListener("scroll", handleScroll, { passive: true });
|
|
311
|
+
scroll?.addEventListener("wheel", handleWheel, { passive: true });
|
|
312
|
+
}, []);
|
|
313
|
+
const contentRef = useRefCallback((content) => {
|
|
314
|
+
state.resizeObserver?.disconnect();
|
|
315
|
+
state.resizeObserver = undefined;
|
|
316
|
+
if (!content) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
let previousHeight;
|
|
320
|
+
state.resizeObserver = new ResizeObserver(([entry]) => {
|
|
321
|
+
const { height } = entry.contentRect;
|
|
322
|
+
const difference = height - (previousHeight ?? height);
|
|
323
|
+
state.resizeDifference = difference;
|
|
324
|
+
/**
|
|
325
|
+
* Sometimes the browser can overscroll past the target,
|
|
326
|
+
* so check for this and adjust appropriately.
|
|
327
|
+
*/
|
|
328
|
+
if (state.scrollTop > state.targetScrollTop) {
|
|
329
|
+
state.scrollTop = state.targetScrollTop;
|
|
330
|
+
}
|
|
331
|
+
setIsNearBottom(state.isNearBottom);
|
|
332
|
+
if (difference >= 0) {
|
|
333
|
+
/**
|
|
334
|
+
* If it's a positive resize, scroll to the bottom when
|
|
335
|
+
* we're already at the bottom.
|
|
336
|
+
*/
|
|
337
|
+
const resize = previousHeight
|
|
338
|
+
? optionsRef.current.resize
|
|
339
|
+
: optionsRef.current.initial;
|
|
340
|
+
if (resize === false) {
|
|
341
|
+
setIsAtBottom(state.isNearBottom);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const animation = mergeAnimations(optionsRef.current, resize);
|
|
345
|
+
scrollToBottom({
|
|
346
|
+
animation,
|
|
347
|
+
wait: true,
|
|
348
|
+
preserveScrollPosition: true,
|
|
349
|
+
duration: animation === "instant"
|
|
350
|
+
? undefined
|
|
351
|
+
: RETAIN_ANIMATION_DURATION_MS,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
/**
|
|
357
|
+
* Else if it's a negative resize, check if we're near the bottom
|
|
358
|
+
* if we are want to un-escape from the lock, because the resize
|
|
359
|
+
* could have caused the container to be at the bottom.
|
|
360
|
+
*/
|
|
361
|
+
if (state.isNearBottom) {
|
|
362
|
+
setEscapedFromLock(false);
|
|
363
|
+
setIsAtBottom(true);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
previousHeight = height;
|
|
367
|
+
/**
|
|
368
|
+
* Reset the resize difference after the scroll event
|
|
369
|
+
* has fired. Requires a rAF to wait for the scroll event,
|
|
370
|
+
* and a setTimeout to wait for the other timeout we have in
|
|
371
|
+
* resizeObserver in case the scroll event happens after the
|
|
372
|
+
* resize event.
|
|
373
|
+
*/
|
|
374
|
+
requestAnimationFrame(() => {
|
|
375
|
+
setTimeout(() => {
|
|
376
|
+
if (state.resizeDifference === difference) {
|
|
377
|
+
state.resizeDifference = 0;
|
|
378
|
+
}
|
|
379
|
+
}, 1);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
state.resizeObserver?.observe(content);
|
|
383
|
+
}, []);
|
|
384
|
+
return {
|
|
385
|
+
contentRef,
|
|
386
|
+
scrollRef,
|
|
387
|
+
scrollToBottom,
|
|
388
|
+
stopScroll,
|
|
389
|
+
isAtBottom: isAtBottom || isNearBottom,
|
|
390
|
+
isNearBottom,
|
|
391
|
+
escapedFromLock,
|
|
392
|
+
state,
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
function useRefCallback(callback, deps) {
|
|
396
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: not needed
|
|
397
|
+
const result = useCallback((ref) => {
|
|
398
|
+
result.current = ref;
|
|
399
|
+
return callback(ref);
|
|
400
|
+
}, deps);
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
const animationCache = new Map();
|
|
404
|
+
function mergeAnimations(...animations) {
|
|
405
|
+
const result = { ...DEFAULT_SPRING_ANIMATION };
|
|
406
|
+
let instant = false;
|
|
407
|
+
for (const animation of animations) {
|
|
408
|
+
if (animation === "instant") {
|
|
409
|
+
instant = true;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (typeof animation !== "object") {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
instant = false;
|
|
416
|
+
result.damping = animation.damping ?? result.damping;
|
|
417
|
+
result.stiffness = animation.stiffness ?? result.stiffness;
|
|
418
|
+
result.mass = animation.mass ?? result.mass;
|
|
419
|
+
}
|
|
420
|
+
const key = JSON.stringify(result);
|
|
421
|
+
if (!animationCache.has(key)) {
|
|
422
|
+
animationCache.set(key, Object.freeze(result));
|
|
423
|
+
}
|
|
424
|
+
return instant ? "instant" : animationCache.get(key);
|
|
425
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "use-stick-to-bottom-extended",
|
|
3
|
+
"author": "Sam Denty <samddenty@gmail.com> (https://samdenty.io/)",
|
|
4
|
+
"contributors": [
|
|
5
|
+
{
|
|
6
|
+
"name": "duz52",
|
|
7
|
+
"url": "https://github.com/duz52/"
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"description": "A lightweight React Hook intended mainly for AI chat applications, with optional resize auto-scroll control",
|
|
11
|
+
"homepage": "https://github.com/duz52/use-stick-to-bottom-extended#readme",
|
|
12
|
+
"private": false,
|
|
13
|
+
"version": "1.2.0",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"funding": [
|
|
19
|
+
{
|
|
20
|
+
"type": "github",
|
|
21
|
+
"url": "https://github.com/sponsors/samdenty"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"main": "dist/index.js",
|
|
26
|
+
"types": "dist/index.d.ts",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "vite",
|
|
30
|
+
"build": "rm -rf dist > /dev/null 2>&1; tsc -b",
|
|
31
|
+
"prepublishOnly": "pnpm build",
|
|
32
|
+
"lint": "biome check",
|
|
33
|
+
"lint:fix": "pnpm lint --write"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/duz52/use-stick-to-bottom-extended.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/duz52/use-stick-to-bottom-extended/issues"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@egoist/tailwindcss-icons": "^1.8.1",
|
|
47
|
+
"@iconify-json/ph": "^1.2.0",
|
|
48
|
+
"@tailwindcss/typography": "^0.5.15",
|
|
49
|
+
"@types/react": "^18.3.3",
|
|
50
|
+
"@types/react-dom": "^18.3.0",
|
|
51
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
52
|
+
"autoprefixer": "^10.4.20",
|
|
53
|
+
"globals": "^15.9.0",
|
|
54
|
+
"lorem-ipsum": "^2.0.8",
|
|
55
|
+
"postcss": "^8.4.45",
|
|
56
|
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
|
57
|
+
"react-dom": "^18.0.0",
|
|
58
|
+
"tailwindcss": "^3.4.10",
|
|
59
|
+
"typescript": "^5.5.3",
|
|
60
|
+
"vite": "^5.4.1",
|
|
61
|
+
"vite-plugin-dts": "^4.2.1",
|
|
62
|
+
"@biomejs/biome": "^1.9.4"
|
|
63
|
+
},
|
|
64
|
+
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee"
|
|
65
|
+
}
|