A message list
A scroller that follows its newest row: what makes a scroller, why query hands back a position rather than a thing, and where a list's width goes.
A thread that grows at the bottom and stays there. Rows arrive animated, the list keeps its own height, and sending scrolls to the newest message without taking the scroll away from you mid-read.
Run it:
rux examples/recipes/message-list.ruxThe scroller is a box, not the screen
Nothing in Rux scrolls until something is told to, and the thing told is the box with the overflow. That takes two declarations and both are load-bearing:
.thread {
width: 100%;
height: 300px;
overflow-y: auto;
}
Without the height there is nothing to overflow, so the box simply grows and
the page runs off the window. Without overflow-y there is no bar and no
wheel. Put the height on the screen instead and the whole document scrolls,
which is a different app.
The width is not a detail
align-items on a flex parent defaults to flex-start here, not CSS's
stretch. That is a deliberate divergence, and children hug their content
because of it. Leave width: 100% off the thread and it is only as wide as its
longest message: the scroller works perfectly and looks broken.
Every box in this recipe that should span its parent says so.
Rows that arrive
<view class="msg" r-for="m in messages" r-key="m.id" r-transition>.msg { transition: opacity 180ms ease-out, transform 180ms ease-out; }
.msg:enter-from { opacity: 0; transform: translateY(10px); }
r-key is doing two jobs at once. It is what lets a row animate at all, because
without an identity an insert and a reorder are the same picture and there is
nothing to hold a row by. And it is what keeps the rows above a new one from
being rebuilt, so a message you are part-way through reading does not flicker
when another arrives.
There is no :leave-to rule here on purpose. Nothing in this recipe removes a
message, so a leaving rule would be one nobody can reach.
Following the newest row
This is the part worth reading twice.
<view class="thread">
<view class="rows">
<view class="msg" r-for="m in messages" r-key="m.id" r-transition>…</view>
</view>
<view class="anchor"></view>
</view>fn toBottom() {
let anchor = query(".anchor");
if anchor.length > 0 {
anchor[0].scrollIntoView();
}
}
The anchor has no content and exists only to be scrolled to. Two things about it are load-bearing, and both come from the same rule.
It has to already exist. query reads the tree that is on screen, which
is the one from before the handler ran. A message pushed onto the list a line
earlier has no element yet, so there is nothing to ask for and nothing to
reveal. Something that was already there can be revealed, and after the rebuild
it sits below the new row.
It has to sit at a position that does not move. query hands back a
path, which is a position among siblings, not an identity. Write the anchor
directly after the r-for and every new message pushes it along by one: the
path captured before the rebuild then names whatever slid into its place, and
the thread scrolls to a message somewhere in the middle. Nothing reports this,
because the reveal lands on a real element, just not the one that was asked
for.
Wrapping the rows in a box of their own fixes it. The anchor is then always the second child of the thread, whatever the list does.
That is also why the gap moved from .thread onto .rows: with the anchor as
a sibling of the row box, a gap on the thread would push it a gap's width past
the last message.
The whole file
<template>
<screen class="app">
<text class="title">a message list</text>
<text class="lead">A list that grows at the bottom and stays there. Send a few, then scroll up and send another: the view follows the newest message without taking the scroll away from you mid-read.</text>
<!-- The scroller is this box, not the screen. Nothing in Rux scrolls until
something is told to, and the thing told is the box with the overflow,
so `.thread` is what gets the height and the bar. Give the height to
the screen instead and the list grows past the window and clips. -->
<view class="thread">
<!-- The rows live in a box of their own, and that box is what makes the
anchor below work. See the comment on the anchor. -->
<view class="rows">
<!-- `r-key` is doing two jobs. It is what lets a row animate in,
because without an identity an insert and a reorder are the same
picture; and it is what keeps the rows above a new one from being
rebuilt, so a message you are reading does not flicker when one
arrives. -->
<view class="msg" :class="#{ mine: m.mine }" r-for="m in messages" r-key="m.id" r-transition>
<text class="who">{{ m.who }}</text>
<text class="body">{{ m.text }}</text>
</view>
</view>
<!-- The anchor: no content, and there only to be scrolled to.
Two things about it are load-bearing. **It has to already exist**,
because `query` reads the tree that is on screen, which is the one
from before the handler ran, so a message just pushed onto the list
has no element yet and cannot be asked for.
And **it has to sit at a position that does not move**, which is why
the rows are wrapped above. `query` hands back a *path*, a position
among siblings, not an identity. Put the anchor directly after the
`r-for` and every new message pushes it along by one, so the path
captured before the rebuild names whatever slid into its place, and
the list scrolls to a message somewhere in the middle instead of to
the end. Nothing reports this: the reveal lands on a real element,
just not the one that was asked for. -->
<view class="anchor"></view>
</view>
<view class="composer">
<input class="field" r-model="draft" placeholder="say something…" @submit="send()" />
<view class="send" :class="#{ dead: draft.trim() == "" }" @tap="send()">
<text class="send-label">send</text>
</view>
</view>
<view class="composer">
<view class="send" @tap="reply()">
<text class="send-label">a message arrives</text>
</view>
</view>
</screen>
</template>
<style>
.app {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
background: #1e1e2e;
height: 100%;
}
.title { color: #cdd6f4; font-size: 22px; font-weight: 700; }
.lead { color: #a6adc8; font-size: 13px; line-height: 20px; }
/* The two lines that make a scroller: a height it cannot exceed, and
permission to overflow. Without the height there is nothing to overflow,
and the box simply grows. */
.thread {
display: flex;
flex-direction: column;
/* `align-items` on a flex parent defaults to `flex-start` here, not CSS's
`stretch`, so a child hugs its content unless it is told to fill. Leave
this out and the thread is only as wide as its longest message, which
looks like a bug in the scroller and is not one. */
width: 100%;
height: 300px;
overflow-y: auto;
padding: 12px;
background: #181825;
border-radius: 12px;
}
/* The gap lives here now rather than on `.thread`, so the anchor is not
pushed a gap's width away from the last message. */
.rows { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.msg {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
background: #313244;
border-radius: 10px;
max-width: 320px;
opacity: 1;
transition: opacity 180ms ease-out, transform 180ms ease-out;
}
/* A row arrives from just below where it lands, which reads as coming up out
of the composer. There is no `:leave-to` here on purpose: nothing removes a
message, so a leaving rule would be a rule nobody can reach. */
.msg:enter-from { opacity: 0; transform: translateY(10px); }
.mine { background: #45475a; align-self: flex-end; }
.who { color: #89b4fa; font-size: 11px; font-weight: 700; }
.body { color: #cdd6f4; font-size: 14px; line-height: 20px; }
/* Height rather than nothing: a zero-height box is still laid out, but giving
it the gap's worth of room means the newest message clears the bottom edge
instead of sitting flush against it. */
.anchor { height: 4px; }
.composer { display: flex; flex-direction: row; gap: 8px; align-items: center; width: 100%; }
.field {
flex: 1;
padding: 10px 12px;
background: #313244;
border: 2px solid #45475a;
border-radius: 10px;
color: #cdd6f4;
font-size: 14px;
}
.field:focus { border-color: #89b4fa; }
.send {
padding: 10px 16px;
background: #89b4fa;
border-radius: 10px;
cursor: pointer;
}
.send:hover { background: #b4befe; }
.send-label { color: #1e1e2e; font-size: 13px; font-weight: 700; }
.dead { background: #45475a; }
.dead .send-label { color: #6c7086; }
</style>
<script>
let draft = signal("");
let nextId = signal(3);
let messages = signal([
#{ id: 1, who: "ada", text: "The scroller is the box with the overflow, not the screen.", mine: false },
#{ id: 2, who: "you", text: "And the list keeps its own height.", mine: true },
]);
// Push, then scroll. The order matters less than it looks: `scrollIntoView`
// does not move anything itself, it asks the host to, and the host acts after
// the tree has been rebuilt with the new row in it.
fn send() {
let text = draft.trim();
if text == "" { return; }
messages.push(#{ id: nextId, who: "you", text: text, mine: true });
nextId++;
draft = "";
toBottom();
}
fn reply() {
messages.push(#{ id: nextId, who: "ada", text: "Message number " + nextId + ", arriving while you read.", mine: false });
nextId++;
toBottom();
}
fn toBottom() {
let anchor = query(".anchor");
if anchor.length > 0 {
anchor[0].scrollIntoView();
}
}
</script>
That is the file in examples/recipes/, verbatim: the one the example tests
walk and the one that was driven in a window before this page was written. The
second button, which makes a message arrive without you sending it, is the case
worth driving: it is how you find out whether the list follows the thread while
you are reading it.
What this recipe found
scrollIntoView did nothing at all for an element below the fold. The shell
picked which scroller to move by asking whose rectangle contained the element,
and that rectangle is the part of the scroller on screen, so a row scrolled
past the bottom belonged to no scroller and the request was dropped in silence.
Only reveals already a nudge away from being visible worked, which is the case
nobody asks for. It matches against the scroller's content now.
That is the argument for writing recipes at all. The pattern is ordinary, the suite was green, and driving it once found a bug in the reveal that had shipped.