javascripthtmlreactjsnext.jsnextui

How do I get a NextUI ListBox to scroll to the last ListItem?


Some background, I'm attempting to create a message chat feed.

I have a NextUI ListBox component that has the property isVirtualized and the appropriate params. It is created using the following:

    <>
        <h1>{selectedServer ? selectedServer.serverAlias : "Chat Title"}</h1>
        <ListboxWrapper ref={containerRef}>
            <Listbox
                isVirtualized
                aria-label="Dynamic Actions"
                items={chats}
                selectedKeys={selectedKeys}
                selectionMode="none"
                onSelectionChange={setSelectedKeys}
                virtualization={{
                    maxListboxHeight: 600,
                    itemHeight: 40,
                }}
            >
                {(item) => (
                    <ListboxItem key={item.id}>
                        {item.text}
                    </ListboxItem>
                )}
            </Listbox>
        </ListboxWrapper>

        <Input
            label="Send Message"
            type={selectedServer ? "text" : "hidden"}
            raduis={"full"}
            onKeyUp={onKeyPressHandler}
            value={textValue}
            onChange={onChangeHandler}
        />
    </>

And the ListboxWrapper:

const ListboxWrapper = ({children}) => (
    <div className="grid row-span-6 w-full h-full border-small px-1 py-2 rounded-small border-default-200 dark:border-default-100">
        {children}
    </div>
);

The items of the list are loaded dynamically. I would like the last item in the list to always be at the bottom of the containing div.

I currently have:

const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({behavior: 'smooth' , block: 'end'})
}

Which scrolls to the top. It doesn't seem to matter what block I set (end or start), the behaviour is the same. Am I doing something wrong or is there an easier way to do this?

I had the same results with document.getElementById(..).scrollIntoView(..). I also moved the ref to the last list item, but when I do that, the last list item appears at the top of the container and the other items are not visible and cannot be scrolled.

Thanks in advanced!

UPDATE:

I tried the solution suggested below but same behaviour.

It seems whenever I type text into the input box, the ListBox scrolls to the top. I have updated the code above to include the full return of the component.

The onKeyPressHandler and onChangeHandler functions with their hooks:

const [selectedKeys, setSelectedKeys] = React.useState(new Set(["text"]));
const selectedValue = React.useMemo(() => Array.from(selectedKeys).join(", "), [selectedKeys]);

const onKeyPressHandler = e => {
    if (e.key === "Enter") {
        const message = new ApiCall()
        const chatRequest = new ChatRequest()
        chatRequest.setChattext(textValue)
        chatRequest.setServerid(selectedServer.serverID)
        message.setChatrequest(chatRequest)
        const apiCall = message.serializeBinary()

        const requestOptions = {
            method: 'POST',
            headers: {'Content-Type': 'application/x-protobuf'},
            body: apiCall
        };

        console.log("fetching chats")
        fetch('http://localhost:8080/api/chats', requestOptions)
            .then(response => {
                if (response.ok) {
                    getChats()
                }

                changeTextValue("");
            })
    }
};

const onChangeHandler = e => {
    changeTextValue(e.target.value);
};

And finally, the getChats function, that gets the chats from the backend and calls scrollToBottom when its complete:

const getChats = () => {
    fetch("http://localhost:8080/api/chats?serverID=" + selectedServer.serverID)
        .then(res => res.json())
        .then(data => {
            setChats(data);
            scrollToBottom()
        })
}

UPDATE2:

I added a console.log to the scrollToBottom function and it is only being called when the chats are updated. However, it isn't scrolling. Every time I type anything into the input box, the list scrolls to the top, almost like it is being redrawn?

UPDATE3:

I went back to the drawing board and did some research into grid and flex layouts. I have also switched from using the virtualised scroll that comes with ListBox to css scrolling using overflow, making the answer below work.


Solution

  • The issue arises because when working with virtualized lists, not all items in the list are rendered in the DOM simultaneously. Virtualization dynamically renders only the visible items to improve performance, so scrollIntoView might not behave as expected because the target item might not actually exist in the DOM.

    To achieve the desired behavior of keeping the last item in view, you'll need to ensure that the virtualization engine correctly accounts for the scroll position. Here's how you can fix this manually:

    Instead of relying on scrollIntoView, you can directly adjust the scroll position of the ListBox container: 1.Add a ref to the container: Attach a ref to the outer container of the ListBox.

    const containerRef = useRef(null);
    

    2.Scroll to the Bottom on Updates: After dynamically loading items, adjust the scroll position manually.

    const scrollToBottom = () => {
        const container = containerRef.current;
        if (container) {
            container.scrollTop = container.scrollHeight;
        }
    };
    

    3.Trigger scrollToBottom: Use useEffect to scroll to the bottom whenever chats (your list of items) updates.

    useEffect(() => {
        scrollToBottom();
    }, [chats]);
    

    4.Attach the ref to the ListboxWrapper: Modify the ListboxWrapper to accept the ref.

    const ListboxWrapper = React.forwardRef(({ children }, ref) => (
        <div
            ref={ref}
            className="grid row-span-6 w-full h-full border-small px-1 py-2 rounded-small border-default-200 dark:border-default-100"
        >
            {children}
        </div>
    ));
    

    5.Pass the ref to the Wrapper: Use the containerRef when rendering the ListboxWrapper.

    <ListboxWrapper ref={containerRef}>
        <Listbox
            isVirtualized
            aria-label="Dynamic Actions"
            items={chats}
            selectedKeys={selectedKeys}
            selectionMode="single"
            onSelectionChange={setSelectedKeys}
            virtualization={{
                maxListboxHeight: 600,
                itemHeight: 40,
            }}
        >
            {(item) => (
                <ListboxItem key={item.id}>
                    {item.text}
                </ListboxItem>
            )}
        </Listbox>
    </ListboxWrapper>
    
    

    If you want smooth scrolling, use scrollTo with the behavior: 'smooth' option instead of directly setting scrollTop.

    const scrollToBottom = () => {
        const container = containerRef.current;
        if (container) {
            container.scrollTo({
                top: container.scrollHeight,
                behavior: 'smooth',
            });
        }
    };