Components

Message scroller

A conversation that follows the newest message while you are at the end, stops when you scroll up, counts what arrives, and holds its place as older messages load.

scheme
direction

Basic #

What is a recipe?
Pieces of style for each slot of a component, picked by its axes.
And a slot?
A named part of the component, which a style or a Mixin reaches.
Can I change one?
Extend the recipe, or add a rule in your app layer.
Which layer wins?
The app layer, after components and variants.
export const MessageScrollerStyle = forSlots(MessageScrollerSlots)(Recipes.MessageScroller({}))
const ConversationArea = forSlots(ScrollAreaSlots)(Recipes.ScrollArea({}))
const ChatControlStyle = forSlots(ButtonSlots)(
  Recipes.Button({ tone: 'neutral', variant: 'outline' }),
)

/** A message of the conversation the scroller holds. */
export const ChatEntry = Schema.Struct({
  id: Schema.String,
  from: Schema.Literals(['user', 'assistant']),
  text: Schema.String,
})
export type ChatEntry = typeof ChatEntry.Type

// Whether it follows the end is the scroller's Model, folded with
// `MessageScroller.update`; the application sends it `Appended` when a
// message lands at the end, and nothing when older ones load above.
export const messageScroller = <Message>(
  style: NamedStyle<typeof MessageScrollerSlots>,
  model: MessageScroller.Model,
  entries: ReadonlyArray<ChatEntry>,
  messages: {
    readonly onMessage: (message: MessageScroller.Message) => Message
    readonly onSend: Message
    readonly onLoadOlder: Message
  },
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Style({ display: 'grid', gap: '0.75rem', inlineSize: 'min(32rem, 100%)' })],
    [
      h.div(
        [h.Style({ blockSize: '20rem' })],
        [
          MessageScroller.view(
            {
              model,
              label: 'Conversation with Ada',
              content: [
                ChatMessage.group(
                  {
                    items: entries.map(({ id, from, text }) => ({ key: id, from, content: text })),
                    style: ChatMessageGroupStyle,
                    messageStyle: ChatMessageStyle,
                  },
                  h,
                ),
              ],
              onMessage: messages.onMessage,
              style,
              areaStyle: ConversationArea,
              areaMixins: ScrollArea.overlayBars,
            },
            h,
          ),
        ],
      ),
      h.div(
        [h.Style({ display: 'flex', gap: '0.5rem' })],
        [
          Button.view({ label: 'Send', onClick: messages.onSend, style: ButtonStyle }, h),
          Button.view(
            { label: 'Load older', onClick: messages.onLoadOlder, style: ChatControlStyle },
            h,
          ),
        ],
      ),
    ],
  )

Usage

Each example above names the function that drew it, over the theme module Create a theme writes. The preview passes it the style picked above instead of its default.

The recipe ↗This code ↗