prime-ui-kit 0.21.4 → 0.21.5

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/dist/index.js CHANGED
@@ -90,7 +90,7 @@ import {
90
90
  useSidebar,
91
91
  useTokenValue,
92
92
  useUploadQueue
93
- } from "./chunks/chunk-UNNQITFE.js";
93
+ } from "./chunks/chunk-CM2KZEDV.js";
94
94
  import {
95
95
  Avatar,
96
96
  Badge,
@@ -125,11 +125,11 @@ import {
125
125
  tierMappings,
126
126
  tones,
127
127
  variants
128
- } from "./chunks/chunk-PFASPQSJ.js";
128
+ } from "./chunks/chunk-XH64AMXF.js";
129
129
  import {
130
130
  ControlSizeProvider,
131
131
  useOptionalControlSize
132
- } from "./chunks/chunk-HMLQCKRL.js";
132
+ } from "./chunks/chunk-FWL3NJYA.js";
133
133
  export {
134
134
  Accordion,
135
135
  AppShell,
@@ -98,11 +98,13 @@ type MessageRowDescription = {
98
98
  * What a top-level child of a list is. Every child is a row (a message, a fragment with one, a
99
99
  * date, a service line, the unread band) except the parts that live at the list's end (`tail`:
100
100
  * typing, the selection bar). A `message` row describes itself; a `sticky` row is a date that
101
- * sticks while its day scrolls.
101
+ * sticks while its day scrolls; any other row may give its `estimate`.
102
102
  */
103
103
  type RowKind = {
104
104
  tail?: boolean;
105
105
  sticky?: boolean;
106
+ /** A row that is not a message: its height before it is measured (px). */
107
+ estimate?: number;
106
108
  message?: (props: Record<string, unknown>) => MessageRowDescription;
107
109
  };
108
110
  /** The parts of the kit declare how a list lays them out (compared by element type). */
@@ -376,11 +376,11 @@ crumb of your own.
376
376
 
377
377
  <!-- icons:start -->
378
378
  `<Icon name="…" />` names:
379
- - `nav.*`: `chevronDown`, `chevronLeft`, `chevronRight`, `chevronUp`, `chevronsLeft`, `chevronsUpDown`, `dashboard`, `home`, `itemDot`, `layoutGrid`, `menu`, `sidebarCollapse`, `sidebarExpand`;
379
+ - `nav.*`: `chevronDown`, `chevronLeft`, `arrowRight`, `chevronRight`, `chevronUp`, `chevronsLeft`, `chevronsUpDown`, `dashboard`, `home`, `itemDot`, `layoutGrid`, `menu`, `sidebarCollapse`, `sidebarExpand`;
380
380
  - `action.*`: `add`, `addContact`, `attach`, `check`, `close`, `copy`, `delete`, `download`, `drag`, `edit`, `externalLink`, `eyedropper`, `filter`, `forward`, `login`, `logout`, `more`, `openApp`, `pause`, `pin`, `play`, `react`, `refresh`, `report`, `remove`, `reply`, `search`, `send`, `settings`, `transcribe`, `upload`, `video`, `voice`, `zoomIn`, `zoomOut`;
381
381
  - `field.*`: `calendar`, `email`, `password.show`, `password.hide`;
382
382
  - `format.*`: `bold`, `code`, `italic`, `link`, `list`, `quote`, `spoiler`, `strikethrough`, `underline`;
383
- - `object.*`: `activity`, `bell`, `bike`, `book`, `cart`, `chart`, `crown`, `disc`, `document`, `file`, `fuel`, `funnel`, `globe`, `headset`, `image`, `inbox`, `key`, `mapPin`, `megaphone`, `message`, `motorbike`, `package`, `phone`, `receipt`, `rocket`, `scale`, `storage`, `tasks`, `trophy`, `truck`, `user`, `users`, `wallet`;
383
+ - `object.*`: `activity`, `bell`, `bike`, `book`, `cart`, `chart`, `crown`, `disc`, `document`, `file`, `fuel`, `funnel`, `globe`, `bot`, `headset`, `image`, `inbox`, `key`, `mapPin`, `megaphone`, `message`, `motorbike`, `package`, `phone`, `receipt`, `rocket`, `scale`, `storage`, `tasks`, `trophy`, `truck`, `user`, `users`, `wallet`;
384
384
  - `sort.*`: `ascending`, `descending`, `none`;
385
385
  - `status.*`: `danger`, `emailSent`, `failed`, `favorite`, `info`, `locked`, `muted`, `sound`, `offline`, `pending`, `read`, `sent`, `success`, `trendUp`, `warning`;
386
386
  - `theme.*`: `dark`, `light`;
@@ -1026,7 +1026,7 @@ What each screen in [the index](#screens) demonstrates — keep these when you s
1026
1026
  - Layout: `Page.Split` (`Page.List`, `Page.Detail`) in a `fill` page: the dialog list `ChatList` (last message, ticks, unread, «печатает…») and the conversation beside it; on a narrow panel one pane at a time with `Page.BackButton` in `Chat.Header`.
1027
1027
  - Search over the dialogs is the field of `ChatList` itself: `search` and `onSearchChange`; you filter the list (by name and last message), and the list shows the match in rows, the number found and an empty result itself. Never put a field of your own above the list.
1028
1028
  - The conversation is `Chat.Root` in the pane's card: the header, `Chat.Search`, `Chat.Messages` and `Chat.Footer` with the Composer. Never assemble your own header, `ScrollContainer` and feed — the chat links search, jumps, selection and «печатает…» itself.
1029
- - Messages are data: `items` with `getKey`, `getAuthor`, `getDate` (service events through `isService`), the first unread is `unread`; the feed computes dates, runs, tails and the avatar place. `renderMessage` may be inline: only a message whose item or place changed renders again, so whatever a row shows lives in its item, and a reply's original comes from `entry.find(key)`.
1029
+ - Messages are data: `items` with `getKey`, `getAuthor`, `getDate` (events of the record — an agent took the client, a status moved — through `isService`, drawn as `Chat.Service` with `Chat.ServiceIcon` and `Chat.ServiceChange`), the first unread is `unread`; the feed computes dates, runs, tails and the avatar place. `renderMessage` may be inline: only a message whose item or place changed renders again, so whatever a row shows lives in its item, and a reply's original comes from `entry.find(key)`.
1030
1030
  - History is `hasOlder` and `onLoadOlder` with a promise: while it runs, a bubble-shaped `Skeleton` placeholder stands on top, and the view does not jump.
1031
1031
  - Each kind of message is its own part of `Message`: text with markup, photos and albums, a file, a `VoicePlayer`; the screen renders the kinds its domain has. The others (`VideoNote`, a location, a contact, a link preview, reactions, a forwarded header) are shown on the Message, VoicePlayer and VideoNote pages; pinned messages and selection on the Chat page.
1032
1032
  - Sending is a `Composer` with `onSend` without waiting: the message is in the feed at once with a clock under a stable id, then one tick, then two. Your own message rises from the input, the client's answer slides in from the side — the feed animates new messages itself; history and status changes are not animated.
@@ -36,7 +36,9 @@ Chat.Root <div> column, fills its host; owns selection,
36
36
  │ ├─ [first-load placeholders] Skeleton bubbles on both sides in the list's place while `loading`
37
37
  │ ├─ [older placeholder] a Skeleton bubble above the first message while `hasOlder`
38
38
  │ ├─ Chat.Separator a date pill, sticky at the top of its day (from `items`, or yours)
39
- │ ├─ Chat.Service an event of the chat, centered pill
39
+ │ ├─ Chat.Service an event of the chat, a centered pill; the time at its end
40
+ │ │ ├─ Chat.ServiceIcon the glyph before the text (aria-hidden)
41
+ │ │ └─ Chat.ServiceChange `from` › `to` (two Badge.Root), read as «с «Лид» на «Арендатор»»
40
42
  │ ├─ Chat.Unread «Непрочитанные сообщения» band (`unread`)
41
43
  │ ├─ Message.Root a message row (see Message)
42
44
  │ ├─ Message.Typing «печатает…» bubble at the end (from `Chat.Root typing`, or yours)
@@ -81,6 +83,7 @@ Chat.Root <div> column, fills its host; owns selection,
81
83
  - `renderMessage` may be an inline function: an item renders again only when it or its place changes, so whatever a row shows lives in its item (an edit, a reaction, a pin). Another message (a reply's original) comes from `entry.find(key)`.
82
84
  - `onRead` reports an incoming message once at least half of it, or half the view, has been on screen while the page is visible (`IntersectionObserver`); batched per frame, each `value` once per mount.
83
85
  - One sound at a time on the page: a `VoicePlayer`, a `VideoNote` and a video in `Message.Media` pause each other; nothing to wire.
86
+ - What happened to the conversation's record (an agent took the client, a stage changed, a manager joined) goes into the list as `Chat.Service` rows — the same items, marked by `isService`, at their time. Events in a row stand as one group; a new one at the end rises in like a message.
84
87
  - Times in a profile's zone: `timeZone` (an IANA name) on `Chat.Messages` moves the date separators, the runs and every message's time to it; without it they follow the browser.
85
88
 
86
89
  ## API
@@ -202,11 +205,31 @@ Chat.Root <div> column, fills its host; owns selection,
202
205
  | `…rest` | `HTMLAttributes<HTMLDivElement>` | — | `children` (the date), `className`. |
203
206
 
204
207
  ### Chat.Service
205
- `ref` → `HTMLDivElement`. An event of the chat, not a message («Анна добавила Олега»): a centered pill that wraps; a row, never a message (with `items`, render it for an `isService` item).
208
+ `ref` → `HTMLDivElement`. An event of the chat, not a message: a centered pill that wraps — `Chat.ServiceIcon`, the words, `Chat.ServiceChange`, the time. A row (for an `isService` item): the arrows, the selection and the search pass it by; events in a row stand as one group.
206
209
 
207
210
  | Prop | Type | Default | Description |
208
211
  |---|---|---|---|
209
- | `…rest` | `HTMLAttributes<HTMLDivElement>` | — | `children` (the event), `className`. |
212
+ | `time` | `Date \| string` | — | When it happened: a `Date` shown with `formatTime` (and put in `<time dateTime>`), or the text to show; muted, at the end of the pill. |
213
+ | `timeZone` | `string` | the list's `timeZone`, else the browser's zone | The zone `time` is read in. |
214
+ | `formatTime` | `(date: Date, timeZone?: string) => string` | «09:05» | Display of a `Date` time; gets the `timeZone` in force. |
215
+ | `…rest` | `HTMLAttributes<HTMLDivElement>` | — | `children` (the event: words, `Chat.ServiceIcon`, `Chat.ServiceChange`, a link), `className`. |
216
+
217
+ ### Chat.ServiceIcon
218
+ `ref` → `HTMLSpanElement`. The glyph before an event's text (who or what acted): an `Icon` at the caption's size, muted, `aria-hidden` — the words name it.
219
+
220
+ | Prop | Type | Default | Description |
221
+ |---|---|---|---|
222
+ | `…rest` | `HTMLAttributes<HTMLSpanElement>` | — | `children` (an `Icon`), `className`. |
223
+
224
+ ### Chat.ServiceChange
225
+ `ref` → `HTMLSpanElement`. A value that changed inside an event: `from` › `to`, two `Badge.Root` with your colours as a rule. It wraps as one unit; assistive tech hears one phrase, «с «Лид» на «Арендатор»» (`labels.change`), the drawing is hidden.
226
+
227
+ | Prop | Type | Default | Description |
228
+ |---|---|---|---|
229
+ | `from` | `ReactNode` | — (required) | The value before (a `Badge.Root`); its text fills `{from}`. |
230
+ | `to` | `ReactNode` | — (required) | The value after; its text fills `{to}`. |
231
+ | `labels` | `Partial<ChatServiceChangeLabels>` | — | `change` — the phrase assistive tech hears. |
232
+ | `…rest` | `Omit<HTMLAttributes<HTMLSpanElement>, "children">` | — | `className` and the other attributes. |
210
233
 
211
234
  ### Chat.Unread
212
235
  `ref` → `HTMLDivElement`. «Непрочитанные сообщения»: a band across the list above the first unread message (`unread` of `Chat.Messages items` puts it there).
@@ -252,6 +275,8 @@ Chat.Root <div> column, fills its host; owns selection,
252
275
  | older history | `hasOlder` | a bubble-shaped `Skeleton` above the first message; reaching it calls `onLoadOlder`; the rows that arrive above keep the reader's place |
253
276
  | jumped to | `Message.Reply target`, `Chat.Pinned`, `jumpTo` | the list brings the message to the middle (a near one glides, a far one lands at once — rendering it first — and settles once its neighbours have laid out); the message itself steps back and comes forward three times, the others stay still (opacity 1 → the dimmed opacity of `Message` → 1, `base` each) — `data-pointing="a"` / `"b"` on the list, flipped on every jump so a repeated jump restarts it, and `data-pointed` with the same `data-blink` on the message; a reply's target also takes the focus, a `jumpTo` change leaves it where it is. While the search is open its current match takes the place of `jumpTo`. Reduced motion: no blink, the others stand back for a moment |
254
277
  | search | `search` (the bar open) | every case-insensitive match in the messages' text and transcripts under a soft accent wash (`<mark>`); the newest match is current: brought into view while the others stay back (`data-pointing="current"`); Enter and the older / newer buttons move the full strength to the older or newer match in one step; «2 из 5» or «Нет совпадений» beside the field |
278
+ | new event | a `Chat.Service` (or a `Chat.Unread`) added at the end after the list mounted, decided from the row keys as messages are | its row floats up `--prime-space-1` and fades in once (`enterMotion.enter`, `fast`; reduced motion keeps the fade). Not on the first render, history or a row the window renders as it scrolls into view |
279
+ | events in a row | consecutive `Chat.Service` rows | one group: `--prime-message-group-gap` between them, the row's `space-3` only around the group |
255
280
  | typing | `typing` | the description cross-fades into «печатает…» in the accent (`Crossfade`); the typing bubble enters at the end of the list like an incoming message and leaves closing its height |
256
281
  | pinned | `Chat.Pinned items` | the rail's current segment in the accent; a press jumps to the message and steps the bar to the older one (the oldest wraps to the newest), its line entering; without items the bar renders nothing |
257
282
  | selection mode | `selected` (not `null`); any of `selected` / `defaultSelected` / `onSelectedChange` adds «Выбрать» to every `Message.Menu` | listbox / options with `aria-selected`; a check at the row start; a click toggles, Shift+click selects the range from the anchor, press-and-drag (or a finger slid from a check) paints the messages it passes with the first one's new state and scrolls the list at its edges; the unselected messages step back to the dimmed opacity of `Message` with a slight desaturation (`fast`, both ways; `data-selecting` on the list, `data-selected` on the message); `Chat.SelectionBar` — the «к последним» button rises above it first (its height upward in `--message-bottom-lift` on the list, `fast`), then the bar fades in, so the two never overlap; outside selection the bar renders nothing |
@@ -263,6 +288,7 @@ Chat.Root <div> column, fills its host; owns selection,
263
288
  - The search bar and the pinned bar stand under the header with `space-3` side padding; the footer pads the Composer by `space-2` / `space-3`.
264
289
  - The list pads its own sides (`space-2`), so the focus rings of the bubbles fit; every row is a container: a bubble is at most `--prime-message-max-width` and 85% of the chat.
265
290
  - Gaps between runs `--prime-message-gap` (16), inside a run `--prime-message-group-gap` (8).
291
+ - A date and an event stand in `space-3` above and below; their pill is a caption on `--prime-color-fill-muted`, `space-1` / `space-3` padding, never wider than a bubble. An event's content is inline and wraps as text: each part (`Chat.ServiceIcon`, `Chat.ServiceChange`, the time) is a box one caption line high; the change wraps as one unit and, wider than the pill, breaks around its arrow. Its content takes the compact tier (`compactTier`), so a change's badges are `xs` and the pill keeps one line's height.
266
292
  - The window: only the rows around the view render, in normal flow between two spacers whose nodes stay while the window moves (sticky dates keep working; the date of the day in view stays rendered). Each row is measured once (one shared `ResizeObserver`) and cached by key; until then it stands at an estimate its message gives from its props (a picture from its aspect, a text from its length). The scroller keeps the first element in view still when anything above it changes (history loaded, rows measured, a message that grows in place) — one correction before paint — never moves a row with a transform, and the window pins rows it must keep: a jump target, the focused message, a playing player.
267
293
  - The «к последним» button is a zero-height sticky row at the end of the list: it floats over the last messages at `space-3` from the bottom-end and takes no room.
268
294
  - Only text selects: the list and every message are `user-select: none`, the text-bearing parts opt back in.
@@ -291,6 +317,7 @@ Chat.Root <div> column, fills its host; owns selection,
291
317
  - `Chat.Pinned` is a button named by its content, the title and the line («Закреплённое 2 из 3» + the text); the rail is decorative; «Открепить» is a separate button.
292
318
  - The «к последним» button is named «К последним сообщениям», with the count «Новых сообщений: N. …»; the badge is `aria-hidden`.
293
319
  - `Chat.SelectionBar` is a `toolbar` named by the count «Выбрано: N», which is announced politely as it changes.
320
+ - `Chat.Service` is a row of the log, not a message: no `article`, no place in the arrows' order or the selection; its text (with the change's phrase) is what the log announces when an event arrives.
294
321
  - The quick reaction (double tap, `L`) is announced politely.
295
322
  - One sound at a time on the page: a voice message, a video note and a chat video pause each other.
296
323
  - «Печатает…» in the header is visible text; in the list the typing bubble is a polite `status` «Собеседник печатает».
@@ -327,11 +354,18 @@ Chat.Root <div> column, fills its host; owns selection,
327
354
  | `pinnedOf` | `"Закреплённое {index} из {count}"` | `Chat.Pinned labels`: the title with several, the newest last. |
328
355
  | `unpin` | `"Открепить"` | `Chat.Pinned labels`: the name of the unpin button. |
329
356
 
357
+ #### Chat.ServiceChange
358
+
359
+ | Key | Default | Used for |
360
+ |---|---|---|
361
+ | `change` | `"с «{from}» на «{to}»"` | `Chat.ServiceChange labels`: the change as assistive tech hears it; `{from}`, `{to}` — the text of each value. |
362
+
330
363
  ## Examples
331
364
  | Example | Shows |
332
365
  |---|---|
333
366
  | [overview.tsx](examples/overview.tsx) | A complete team messenger over two thousand messages: the header whose status turns into typing, search with highlight, pinned messages, history that loads above, author headers with tags in a group chat, every kind of message, transcripts, reactions, the menu with reply, edit, pin and select, jumps, the new-messages button and the Composer whose messages rise into the list — `Chat.Root`, `Chat.Header`, `Chat.Search`, `Chat.Pinned`, `Chat.Messages`, `group`, `items`, `renderMessage`, `typing`, `selected`, `Chat.Footer`. |
334
367
  | [structure.tsx](examples/structure.tsx) | The list alone, written as parts: `Chat.Root` with only `Chat.Messages`, a date that stays on top while its day scrolls, an event of the chat and the unread band — `Chat.Messages`, `Chat.Separator`, `Chat.Service`, `Chat.Unread`. |
368
+ | [events.tsx](examples/events.tsx) | What happened to the client, right in the dialog: who took it, a stage it moved to, a manager who joined; events in a row stand as one group, a new one rises in at the end — `isService`, `Chat.Service`, `Chat.ServiceIcon`, `Chat.ServiceChange`, `time`. |
335
369
  | [group-chat.tsx](examples/group-chat.tsx) | A team chat from data: the first message of an author's run opens with the name in the author's hue, a crown for the owner and the member's tag; the avatar — a photo, or initials — closes the run, which holds messages up to a quarter of an hour apart; the dates carry the weekday — `group`, `items`, `renderMessage`, `groupWindow`, `formatDate`, `Message.Author`, `tag`, `Message.AuthorIcon`, `hueOf`. |
336
370
  | [reactions.tsx](examples/reactions.tsx) | A team's own reactions for approvals: a double tap or `L` puts the approval mark, a long press or `R` opens the team's set, and a chip toggles them back — `quickReaction`, `reactionOptions`, `onReactionToggle`. |
337
371
  | [live.tsx](examples/live.tsx) | A live support chat: the client types — the header and the end of the list say so — then answers; new messages enter at the end, read receipts come from what is on screen, and scrolled up the round button counts what arrived — `typing`, `onNewMessage`, `onRead`. |
@@ -340,7 +374,7 @@ Chat.Root <div> column, fills its host; owns selection,
340
374
  | [states.tsx](examples/states.tsx) | The first load of a conversation: bubble-shaped placeholders on both sides stand in the list's place, then cross-fade into the messages — `loading`. |
341
375
  | [controlled.tsx](examples/controlled.tsx) | Selection mode owned by the screen: the Select item of a message's menu starts it, a check stands beside every message, a press toggles one while the others step back, the bar counts and acts, Escape leaves — `selected`, `onSelectedChange`, `Chat.SelectionBar`. |
342
376
  | [narrow.tsx](examples/narrow.tsx) | At phone width the header keeps its actions and lets the title ellipsize, bubbles take up to 85% of the chat, long links wrap and an album keeps its layout — `Chat.Root`, `Chat.Header`, `Chat.Messages`, `Chat.Footer`. |
343
- | [screen.tsx](examples/screen.tsx) | A support messenger as a work screen: the title and the unread count in its top bar, the dialogs with a search over them beside the conversation (one pane at a time on a phone), history that loads above as you scroll up, the unread band, reply and edit from the menu, the typing signal both ways, unread counts from what was seen, and the Composer whose messages rise into the list while the client answers — `items`, `renderMessage`, `hasOlder`, `onLoadOlder`, `typing`. |
377
+ | [screen.tsx](examples/screen.tsx) | A support messenger as a work screen: the title and the unread count in its top bar, the dialogs with a search over them beside the conversation (one pane at a time on a phone), history that loads above as you scroll up, the unread band, reply and edit from the menu, the typing signal both ways, the request's events (an agent took it, its status moved, you joined) between the messages, unread counts from what was seen, and the Composer whose messages rise into the list while the client answers — `items`, `renderMessage`, `isService`, `hasOlder`, `onLoadOlder`, `typing`. |
344
378
 
345
379
  ## Mistakes
346
380
  - A header, a search bar and a scroller of your own around the messages → `Chat.Header`, `Chat.Search`, `Chat.Messages`: the chat wires typing, search, jumps and selection between them.
@@ -355,9 +389,11 @@ Chat.Root <div> column, fills its host; owns selection,
355
389
  - Marking messages read on arrival → `onRead` reports what was really on screen while the page was visible.
356
390
  - «печатает…» as a text you swap in the header and a row you add to the list → `Chat.Root typing`: both move together, smoothly.
357
391
  - Search results computed and scrolled to by hand → `Chat.Search`: it finds, counts, marks and brings matches into view, the focus staying in the field.
392
+ - A chat event as a `Message.Root` from «system», or a `Notification` → `Chat.Service`: a row of the list, outside the arrows, the selection and the search.
393
+ - A change written as «Лид → Арендатор» in the text → `Chat.ServiceChange from to`: the values keep their colours and assistive tech hears «с «Лид» на «Арендатор»».
358
394
  - `scrollIntoView` to show a pinned or quoted message → `Chat.Pinned` and `Message.Reply target` jump in the chat's own scroller and point at the message.
359
395
  - Checkboxes and selection handling of your own → `Chat.Root selected` with `Chat.SelectionBar`: click, Shift range and drag like DataTable rows.
360
396
 
361
397
  ## Related
362
- - **Built from:** [Message](../message/COMPONENT.md) (`Message.Typing`, the rows), [ScrollContainer](../scroll-container/COMPONENT.md) (the scroller, `anchor="end"`), [Input](../input/COMPONENT.md) (the search field), [Button](../button/COMPONENT.md) (search, steps, unpin, «к последним», the selection bar), [Badge](../badge/COMPONENT.md), [Crossfade](../crossfade/COMPONENT.md) (the status line, the first load), [Skeleton](../skeleton/COMPONENT.md) (the first load, older history), [VisuallyHidden](../visually-hidden/COMPONENT.md) (the loading announcement), Icon (`action.search`, `nav.chevronUp`, `nav.chevronDown`, `action.pin`, `action.close`); `useVirtualRows` (the window), `internal/messageList` (the contract between the list and its messages), `internal/readReceipts`, `internal/useRangeSelection` (selection by click, Shift and drag), `internal/messagePart` (the search query its texts mark)
398
+ - **Built from:** [Message](../message/COMPONENT.md) (`Message.Typing`, the rows), [ScrollContainer](../scroll-container/COMPONENT.md) (the scroller, `anchor="end"`), [Input](../input/COMPONENT.md) (the search field), [Button](../button/COMPONENT.md) (search, steps, unpin, «к последним», the selection bar), [Badge](../badge/COMPONENT.md), [Crossfade](../crossfade/COMPONENT.md) (the status line, the first load), [Skeleton](../skeleton/COMPONENT.md) (the first load, older history), [VisuallyHidden](../visually-hidden/COMPONENT.md) (the loading announcement, the phrase of a change), Icon (`action.search`, `nav.chevronUp`, `nav.chevronDown`, `nav.chevronRight`, `action.pin`, `action.close`); `useVirtualRows` (the window), `internal/messageList` (the contract between the list and its messages), `internal/readReceipts`, `internal/useRangeSelection` (selection by click, Shift and drag), `internal/messagePart` (the search query its texts mark)
363
399
  - **See also:** [Message](../message/COMPONENT.md) — one message and every content part; [Composer](../composer/COMPONENT.md) — the input in `Chat.Footer`; [Gallery](../gallery/COMPONENT.md), [VoicePlayer](../voice-player/COMPONENT.md), [VideoNote](../video-note/COMPONENT.md), [Timeline](../timeline/COMPONENT.md)
@@ -0,0 +1,129 @@
1
+ /** What happened to the client, right in the dialog: who took it, a stage it moved to, a manager who joined; events in a row stand as one group, a new one rises in at the end — `isService`, `Chat.Service`, `Chat.ServiceIcon`, `Chat.ServiceChange`, `time`. */
2
+ import { Badge, Button, Card, Chat, Icon, Message } from "prime-ui-kit";
3
+ import * as React from "react";
4
+
5
+ import styles from "./examples.module.css";
6
+
7
+ const NOW = new Date(2026, 9, 15, 11, 0);
8
+ const at = (hour: number, minute: number) => new Date(2026, 9, 15, hour, minute);
9
+
10
+ type DialogItem =
11
+ | { id: string; kind: "message"; author: "client" | "agent"; sentAt: Date; text: string }
12
+ | { id: string; kind: "taken"; sentAt: Date }
13
+ | { id: string; kind: "stage"; sentAt: Date; from: string; to: string }
14
+ | { id: string; kind: "joined"; sentAt: Date; manager: string };
15
+
16
+ const DIALOG: DialogItem[] = [
17
+ {
18
+ id: "ev-1",
19
+ kind: "message",
20
+ author: "client",
21
+ sentAt: at(10, 12),
22
+ text: "Здравствуйте! Ищу склад под аренду, 100–150 м², с 1 ноября.",
23
+ },
24
+ { id: "ev-2", kind: "taken", sentAt: at(10, 12) },
25
+ { id: "ev-3", kind: "stage", sentAt: at(10, 12), from: "Лид", to: "Арендатор" },
26
+ {
27
+ id: "ev-4",
28
+ kind: "message",
29
+ author: "agent",
30
+ sentAt: at(10, 13),
31
+ text: "Добрый день! Есть склад 120 м² на Складской, 4, свободен с 25 октября. Прислать планировку?",
32
+ },
33
+ {
34
+ id: "ev-5",
35
+ kind: "message",
36
+ author: "client",
37
+ sentAt: at(10, 15),
38
+ text: "Да, и хотелось бы обсудить скидку за год вперёд.",
39
+ },
40
+ ];
41
+
42
+ const JOINED: DialogItem = {
43
+ id: "ev-6",
44
+ kind: "joined",
45
+ sentAt: at(10, 16),
46
+ manager: "Анна Котова",
47
+ };
48
+
49
+ function renderItem(item: DialogItem) {
50
+ if (item.kind === "taken") {
51
+ return (
52
+ <Chat.Service time={item.sentAt}>
53
+ <Chat.ServiceIcon>
54
+ <Icon name="object.bot" />
55
+ </Chat.ServiceIcon>
56
+ ИИ-агент взял клиента
57
+ </Chat.Service>
58
+ );
59
+ }
60
+ if (item.kind === "stage") {
61
+ return (
62
+ <Chat.Service time={item.sentAt}>
63
+ <Chat.ServiceIcon>
64
+ <Icon name="object.funnel" />
65
+ </Chat.ServiceIcon>
66
+ ИИ-агент перевёл клиента
67
+ <Chat.ServiceChange
68
+ from={<Badge.Root color="gray">{item.from}</Badge.Root>}
69
+ to={<Badge.Root color="green">{item.to}</Badge.Root>}
70
+ />
71
+ </Chat.Service>
72
+ );
73
+ }
74
+ if (item.kind === "joined") {
75
+ return (
76
+ <Chat.Service time={item.sentAt}>
77
+ <Chat.ServiceIcon>
78
+ <Icon name="object.user" />
79
+ </Chat.ServiceIcon>
80
+ Менеджер {item.manager} подключилась к диалогу
81
+ </Chat.Service>
82
+ );
83
+ }
84
+ return (
85
+ <Message.Root
86
+ direction={item.author === "agent" ? "outgoing" : "incoming"}
87
+ time={item.sentAt}
88
+ status={item.author === "agent" ? "read" : undefined}
89
+ >
90
+ <Message.Text>{item.text}</Message.Text>
91
+ </Message.Root>
92
+ );
93
+ }
94
+
95
+ export default function ChatEventsExample() {
96
+ const [items, setItems] = React.useState(DIALOG);
97
+ const joined = items.includes(JOINED);
98
+
99
+ return (
100
+ <div className={styles.live}>
101
+ <div className={styles.liveBar}>
102
+ <Button.Root
103
+ variant="soft"
104
+ disabled={joined}
105
+ onClick={() => setItems((current) => [...current, JOINED])}
106
+ >
107
+ <Button.Icon>
108
+ <Icon name="action.addContact" />
109
+ </Button.Icon>
110
+ Подключить менеджера
111
+ </Button.Root>
112
+ </div>
113
+ <Card.Root className={styles.short}>
114
+ <Chat.Root>
115
+ <Chat.Messages
116
+ aria-label="Диалог с клиентом «Склад на Складской»"
117
+ now={NOW}
118
+ items={items}
119
+ getKey={(item: DialogItem) => item.id}
120
+ getAuthor={(item: DialogItem) => (item.kind === "message" ? item.author : "system")}
121
+ getDate={(item: DialogItem) => item.sentAt}
122
+ isService={(item: DialogItem) => item.kind !== "message"}
123
+ renderMessage={renderItem}
124
+ />
125
+ </Chat.Root>
126
+ </Card.Root>
127
+ </div>
128
+ );
129
+ }
@@ -1,6 +1,7 @@
1
- /** A support messenger as a work screen: the title and the unread count in its top bar, the dialogs with a search over them beside the conversation (one pane at a time on a phone), history that loads above as you scroll up, the unread band, reply and edit from the menu, the typing signal both ways, unread counts from what was seen, and the Composer whose messages rise into the list while the client answers — `items`, `renderMessage`, `hasOlder`, `onLoadOlder`, `typing`. */
1
+ /** A support messenger as a work screen: the title and the unread count in its top bar, the dialogs with a search over them beside the conversation (one pane at a time on a phone), history that loads above as you scroll up, the unread band, reply and edit from the menu, the typing signal both ways, the request's events (an agent took it, its status moved, you joined) between the messages, unread counts from what was seen, and the Composer whose messages rise into the list while the client answers — `items`, `renderMessage`, `isService`, `hasOlder`, `onLoadOlder`, `typing`. */
2
2
  import {
3
3
  Avatar,
4
+ Badge,
4
5
  Card,
5
6
  Chat,
6
7
  ChatList,
@@ -28,7 +29,13 @@ type Content =
28
29
  | { kind: "text"; text: string }
29
30
  | { kind: "media"; items: GalleryItem[]; caption?: string }
30
31
  | { kind: "voice"; src: string; waveform: number[]; duration: number }
31
- | { kind: "file"; name: string; size: number; href: string };
32
+ | { kind: "file"; name: string; size: number; href: string }
33
+ | {
34
+ kind: "event";
35
+ icon: "object.bot" | "object.funnel" | "object.user";
36
+ text: string;
37
+ change?: { from: string; to: string };
38
+ };
32
39
 
33
40
  type ChatMessage = Content & {
34
41
  id: string;
@@ -129,6 +136,31 @@ const HISTORY: ChatMessage[] = [
129
136
  ],
130
137
  caption: "Вот так выглядит",
131
138
  },
139
+ {
140
+ id: "e1",
141
+ from: "client",
142
+ sentAt: at(7, 10, 14),
143
+ kind: "event",
144
+ icon: "object.bot",
145
+ text: "ИИ-агент взял обращение",
146
+ },
147
+ {
148
+ id: "e2",
149
+ from: "client",
150
+ sentAt: at(7, 10, 14),
151
+ kind: "event",
152
+ icon: "object.funnel",
153
+ text: "ИИ-агент перевёл обращение",
154
+ change: { from: "Новое", to: "В работе" },
155
+ },
156
+ {
157
+ id: "e3",
158
+ from: "me",
159
+ sentAt: at(7, 10, 19),
160
+ kind: "event",
161
+ icon: "object.user",
162
+ text: "Вы подключились к диалогу",
163
+ },
132
164
  {
133
165
  id: "m3",
134
166
  from: "me",
@@ -228,6 +260,8 @@ const excerpt = (message: ChatMessage) => {
228
260
  );
229
261
  case "file":
230
262
  return message.name;
263
+ case "event":
264
+ return message.text;
231
265
  }
232
266
  };
233
267
 
@@ -260,6 +294,8 @@ const renderContent = (message: ChatMessage) => {
260
294
  );
261
295
  case "file":
262
296
  return <Message.File name={message.name} fileSize={message.size} href={message.href} />;
297
+ case "event":
298
+ return null;
263
299
  }
264
300
  };
265
301
 
@@ -522,6 +558,23 @@ export default function ChatScreenExample() {
522
558
  // re-renders the one message it touches. A dialog loads its own messages, so a row never
523
559
  // outlives the contact it names; a reply's original comes from the list (`entry.find`).
524
560
  const renderMessage = (message: ChatMessage, entry: ChatMessageEntry<ChatMessage>) => {
561
+ // What happened to the request (an agent took it, its status moved, you joined): a row.
562
+ if (message.kind === "event") {
563
+ return (
564
+ <Chat.Service time={message.sentAt}>
565
+ <Chat.ServiceIcon>
566
+ <Icon name={message.icon} />
567
+ </Chat.ServiceIcon>
568
+ {message.text}
569
+ {message.change ? (
570
+ <Chat.ServiceChange
571
+ from={<Badge.Root color="gray">{message.change.from}</Badge.Root>}
572
+ to={<Badge.Root color="blue">{message.change.to}</Badge.Root>}
573
+ />
574
+ ) : null}
575
+ </Chat.Service>
576
+ );
577
+ }
525
578
  const own = message.from === "me";
526
579
  const original = message.replyTo ? entry.find(message.replyTo) : undefined;
527
580
  return (
@@ -645,6 +698,7 @@ export default function ChatScreenExample() {
645
698
  getKey={(message) => message.id}
646
699
  getAuthor={(message) => message.from}
647
700
  getDate={(message) => message.sentAt}
701
+ isService={(message) => message.kind === "event"}
648
702
  renderMessage={renderMessage}
649
703
  unread={FIRST_UNREAD}
650
704
  now={NOW}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prime-ui-kit",
3
- "version": "0.21.4",
3
+ "version": "0.21.5",
4
4
  "packageManager": "bun@1.3.13",
5
5
  "description": "React 19 UI kit for product interfaces: 79 composable components on one design contract — forms, selects, tables, overlays, navigation, app shell. CSS Modules, --prime-* tokens, light and dark themes, TypeScript, a11y, Next.js App Router ready. Ships COMPONENT.md docs, copyable examples and an agent skill.",
6
6
  "bin": {
@@ -36,7 +36,9 @@ Chat.Root <div> column, fills its host; owns selection,
36
36
  │ ├─ [first-load placeholders] Skeleton bubbles on both sides in the list's place while `loading`
37
37
  │ ├─ [older placeholder] a Skeleton bubble above the first message while `hasOlder`
38
38
  │ ├─ Chat.Separator a date pill, sticky at the top of its day (from `items`, or yours)
39
- │ ├─ Chat.Service an event of the chat, centered pill
39
+ │ ├─ Chat.Service an event of the chat, a centered pill; the time at its end
40
+ │ │ ├─ Chat.ServiceIcon the glyph before the text (aria-hidden)
41
+ │ │ └─ Chat.ServiceChange `from` › `to` (two Badge.Root), read as «с «Лид» на «Арендатор»»
40
42
  │ ├─ Chat.Unread «Непрочитанные сообщения» band (`unread`)
41
43
  │ ├─ Message.Root a message row (see Message)
42
44
  │ ├─ Message.Typing «печатает…» bubble at the end (from `Chat.Root typing`, or yours)
@@ -81,6 +83,7 @@ Chat.Root <div> column, fills its host; owns selection,
81
83
  - `renderMessage` may be an inline function: an item renders again only when it or its place changes, so whatever a row shows lives in its item (an edit, a reaction, a pin). Another message (a reply's original) comes from `entry.find(key)`.
82
84
  - `onRead` reports an incoming message once at least half of it, or half the view, has been on screen while the page is visible (`IntersectionObserver`); batched per frame, each `value` once per mount.
83
85
  - One sound at a time on the page: a `VoicePlayer`, a `VideoNote` and a video in `Message.Media` pause each other; nothing to wire.
86
+ - What happened to the conversation's record (an agent took the client, a stage changed, a manager joined) goes into the list as `Chat.Service` rows — the same items, marked by `isService`, at their time. Events in a row stand as one group; a new one at the end rises in like a message.
84
87
  - Times in a profile's zone: `timeZone` (an IANA name) on `Chat.Messages` moves the date separators, the runs and every message's time to it; without it they follow the browser.
85
88
 
86
89
  ## API
@@ -202,11 +205,31 @@ Chat.Root <div> column, fills its host; owns selection,
202
205
  | `…rest` | `HTMLAttributes<HTMLDivElement>` | — | `children` (the date), `className`. |
203
206
 
204
207
  ### Chat.Service
205
- `ref` → `HTMLDivElement`. An event of the chat, not a message («Анна добавила Олега»): a centered pill that wraps; a row, never a message (with `items`, render it for an `isService` item).
208
+ `ref` → `HTMLDivElement`. An event of the chat, not a message: a centered pill that wraps — `Chat.ServiceIcon`, the words, `Chat.ServiceChange`, the time. A row (for an `isService` item): the arrows, the selection and the search pass it by; events in a row stand as one group.
206
209
 
207
210
  | Prop | Type | Default | Description |
208
211
  |---|---|---|---|
209
- | `…rest` | `HTMLAttributes<HTMLDivElement>` | — | `children` (the event), `className`. |
212
+ | `time` | `Date \| string` | — | When it happened: a `Date` shown with `formatTime` (and put in `<time dateTime>`), or the text to show; muted, at the end of the pill. |
213
+ | `timeZone` | `string` | the list's `timeZone`, else the browser's zone | The zone `time` is read in. |
214
+ | `formatTime` | `(date: Date, timeZone?: string) => string` | «09:05» | Display of a `Date` time; gets the `timeZone` in force. |
215
+ | `…rest` | `HTMLAttributes<HTMLDivElement>` | — | `children` (the event: words, `Chat.ServiceIcon`, `Chat.ServiceChange`, a link), `className`. |
216
+
217
+ ### Chat.ServiceIcon
218
+ `ref` → `HTMLSpanElement`. The glyph before an event's text (who or what acted): an `Icon` at the caption's size, muted, `aria-hidden` — the words name it.
219
+
220
+ | Prop | Type | Default | Description |
221
+ |---|---|---|---|
222
+ | `…rest` | `HTMLAttributes<HTMLSpanElement>` | — | `children` (an `Icon`), `className`. |
223
+
224
+ ### Chat.ServiceChange
225
+ `ref` → `HTMLSpanElement`. A value that changed inside an event: `from` › `to`, two `Badge.Root` with your colours as a rule. It wraps as one unit; assistive tech hears one phrase, «с «Лид» на «Арендатор»» (`labels.change`), the drawing is hidden.
226
+
227
+ | Prop | Type | Default | Description |
228
+ |---|---|---|---|
229
+ | `from` | `ReactNode` | — (required) | The value before (a `Badge.Root`); its text fills `{from}`. |
230
+ | `to` | `ReactNode` | — (required) | The value after; its text fills `{to}`. |
231
+ | `labels` | `Partial<ChatServiceChangeLabels>` | — | `change` — the phrase assistive tech hears. |
232
+ | `…rest` | `Omit<HTMLAttributes<HTMLSpanElement>, "children">` | — | `className` and the other attributes. |
210
233
 
211
234
  ### Chat.Unread
212
235
  `ref` → `HTMLDivElement`. «Непрочитанные сообщения»: a band across the list above the first unread message (`unread` of `Chat.Messages items` puts it there).
@@ -252,6 +275,8 @@ Chat.Root <div> column, fills its host; owns selection,
252
275
  | older history | `hasOlder` | a bubble-shaped `Skeleton` above the first message; reaching it calls `onLoadOlder`; the rows that arrive above keep the reader's place |
253
276
  | jumped to | `Message.Reply target`, `Chat.Pinned`, `jumpTo` | the list brings the message to the middle (a near one glides, a far one lands at once — rendering it first — and settles once its neighbours have laid out); the message itself steps back and comes forward three times, the others stay still (opacity 1 → the dimmed opacity of `Message` → 1, `base` each) — `data-pointing="a"` / `"b"` on the list, flipped on every jump so a repeated jump restarts it, and `data-pointed` with the same `data-blink` on the message; a reply's target also takes the focus, a `jumpTo` change leaves it where it is. While the search is open its current match takes the place of `jumpTo`. Reduced motion: no blink, the others stand back for a moment |
254
277
  | search | `search` (the bar open) | every case-insensitive match in the messages' text and transcripts under a soft accent wash (`<mark>`); the newest match is current: brought into view while the others stay back (`data-pointing="current"`); Enter and the older / newer buttons move the full strength to the older or newer match in one step; «2 из 5» or «Нет совпадений» beside the field |
278
+ | new event | a `Chat.Service` (or a `Chat.Unread`) added at the end after the list mounted, decided from the row keys as messages are | its row floats up `--prime-space-1` and fades in once (`enterMotion.enter`, `fast`; reduced motion keeps the fade). Not on the first render, history or a row the window renders as it scrolls into view |
279
+ | events in a row | consecutive `Chat.Service` rows | one group: `--prime-message-group-gap` between them, the row's `space-3` only around the group |
255
280
  | typing | `typing` | the description cross-fades into «печатает…» in the accent (`Crossfade`); the typing bubble enters at the end of the list like an incoming message and leaves closing its height |
256
281
  | pinned | `Chat.Pinned items` | the rail's current segment in the accent; a press jumps to the message and steps the bar to the older one (the oldest wraps to the newest), its line entering; without items the bar renders nothing |
257
282
  | selection mode | `selected` (not `null`); any of `selected` / `defaultSelected` / `onSelectedChange` adds «Выбрать» to every `Message.Menu` | listbox / options with `aria-selected`; a check at the row start; a click toggles, Shift+click selects the range from the anchor, press-and-drag (or a finger slid from a check) paints the messages it passes with the first one's new state and scrolls the list at its edges; the unselected messages step back to the dimmed opacity of `Message` with a slight desaturation (`fast`, both ways; `data-selecting` on the list, `data-selected` on the message); `Chat.SelectionBar` — the «к последним» button rises above it first (its height upward in `--message-bottom-lift` on the list, `fast`), then the bar fades in, so the two never overlap; outside selection the bar renders nothing |
@@ -263,6 +288,7 @@ Chat.Root <div> column, fills its host; owns selection,
263
288
  - The search bar and the pinned bar stand under the header with `space-3` side padding; the footer pads the Composer by `space-2` / `space-3`.
264
289
  - The list pads its own sides (`space-2`), so the focus rings of the bubbles fit; every row is a container: a bubble is at most `--prime-message-max-width` and 85% of the chat.
265
290
  - Gaps between runs `--prime-message-gap` (16), inside a run `--prime-message-group-gap` (8).
291
+ - A date and an event stand in `space-3` above and below; their pill is a caption on `--prime-color-fill-muted`, `space-1` / `space-3` padding, never wider than a bubble. An event's content is inline and wraps as text: each part (`Chat.ServiceIcon`, `Chat.ServiceChange`, the time) is a box one caption line high; the change wraps as one unit and, wider than the pill, breaks around its arrow. Its content takes the compact tier (`compactTier`), so a change's badges are `xs` and the pill keeps one line's height.
266
292
  - The window: only the rows around the view render, in normal flow between two spacers whose nodes stay while the window moves (sticky dates keep working; the date of the day in view stays rendered). Each row is measured once (one shared `ResizeObserver`) and cached by key; until then it stands at an estimate its message gives from its props (a picture from its aspect, a text from its length). The scroller keeps the first element in view still when anything above it changes (history loaded, rows measured, a message that grows in place) — one correction before paint — never moves a row with a transform, and the window pins rows it must keep: a jump target, the focused message, a playing player.
267
293
  - The «к последним» button is a zero-height sticky row at the end of the list: it floats over the last messages at `space-3` from the bottom-end and takes no room.
268
294
  - Only text selects: the list and every message are `user-select: none`, the text-bearing parts opt back in.
@@ -291,6 +317,7 @@ Chat.Root <div> column, fills its host; owns selection,
291
317
  - `Chat.Pinned` is a button named by its content, the title and the line («Закреплённое 2 из 3» + the text); the rail is decorative; «Открепить» is a separate button.
292
318
  - The «к последним» button is named «К последним сообщениям», with the count «Новых сообщений: N. …»; the badge is `aria-hidden`.
293
319
  - `Chat.SelectionBar` is a `toolbar` named by the count «Выбрано: N», which is announced politely as it changes.
320
+ - `Chat.Service` is a row of the log, not a message: no `article`, no place in the arrows' order or the selection; its text (with the change's phrase) is what the log announces when an event arrives.
294
321
  - The quick reaction (double tap, `L`) is announced politely.
295
322
  - One sound at a time on the page: a voice message, a video note and a chat video pause each other.
296
323
  - «Печатает…» in the header is visible text; in the list the typing bubble is a polite `status` «Собеседник печатает».
@@ -327,11 +354,18 @@ Chat.Root <div> column, fills its host; owns selection,
327
354
  | `pinnedOf` | `"Закреплённое {index} из {count}"` | `Chat.Pinned labels`: the title with several, the newest last. |
328
355
  | `unpin` | `"Открепить"` | `Chat.Pinned labels`: the name of the unpin button. |
329
356
 
357
+ #### Chat.ServiceChange
358
+
359
+ | Key | Default | Used for |
360
+ |---|---|---|
361
+ | `change` | `"с «{from}» на «{to}»"` | `Chat.ServiceChange labels`: the change as assistive tech hears it; `{from}`, `{to}` — the text of each value. |
362
+
330
363
  ## Examples
331
364
  | Example | Shows |
332
365
  |---|---|
333
366
  | [overview.tsx](examples/overview.tsx) | A complete team messenger over two thousand messages: the header whose status turns into typing, search with highlight, pinned messages, history that loads above, author headers with tags in a group chat, every kind of message, transcripts, reactions, the menu with reply, edit, pin and select, jumps, the new-messages button and the Composer whose messages rise into the list — `Chat.Root`, `Chat.Header`, `Chat.Search`, `Chat.Pinned`, `Chat.Messages`, `group`, `items`, `renderMessage`, `typing`, `selected`, `Chat.Footer`. |
334
367
  | [structure.tsx](examples/structure.tsx) | The list alone, written as parts: `Chat.Root` with only `Chat.Messages`, a date that stays on top while its day scrolls, an event of the chat and the unread band — `Chat.Messages`, `Chat.Separator`, `Chat.Service`, `Chat.Unread`. |
368
+ | [events.tsx](examples/events.tsx) | What happened to the client, right in the dialog: who took it, a stage it moved to, a manager who joined; events in a row stand as one group, a new one rises in at the end — `isService`, `Chat.Service`, `Chat.ServiceIcon`, `Chat.ServiceChange`, `time`. |
335
369
  | [group-chat.tsx](examples/group-chat.tsx) | A team chat from data: the first message of an author's run opens with the name in the author's hue, a crown for the owner and the member's tag; the avatar — a photo, or initials — closes the run, which holds messages up to a quarter of an hour apart; the dates carry the weekday — `group`, `items`, `renderMessage`, `groupWindow`, `formatDate`, `Message.Author`, `tag`, `Message.AuthorIcon`, `hueOf`. |
336
370
  | [reactions.tsx](examples/reactions.tsx) | A team's own reactions for approvals: a double tap or `L` puts the approval mark, a long press or `R` opens the team's set, and a chip toggles them back — `quickReaction`, `reactionOptions`, `onReactionToggle`. |
337
371
  | [live.tsx](examples/live.tsx) | A live support chat: the client types — the header and the end of the list say so — then answers; new messages enter at the end, read receipts come from what is on screen, and scrolled up the round button counts what arrived — `typing`, `onNewMessage`, `onRead`. |
@@ -340,7 +374,7 @@ Chat.Root <div> column, fills its host; owns selection,
340
374
  | [states.tsx](examples/states.tsx) | The first load of a conversation: bubble-shaped placeholders on both sides stand in the list's place, then cross-fade into the messages — `loading`. |
341
375
  | [controlled.tsx](examples/controlled.tsx) | Selection mode owned by the screen: the Select item of a message's menu starts it, a check stands beside every message, a press toggles one while the others step back, the bar counts and acts, Escape leaves — `selected`, `onSelectedChange`, `Chat.SelectionBar`. |
342
376
  | [narrow.tsx](examples/narrow.tsx) | At phone width the header keeps its actions and lets the title ellipsize, bubbles take up to 85% of the chat, long links wrap and an album keeps its layout — `Chat.Root`, `Chat.Header`, `Chat.Messages`, `Chat.Footer`. |
343
- | [screen.tsx](examples/screen.tsx) | A support messenger as a work screen: the title and the unread count in its top bar, the dialogs with a search over them beside the conversation (one pane at a time on a phone), history that loads above as you scroll up, the unread band, reply and edit from the menu, the typing signal both ways, unread counts from what was seen, and the Composer whose messages rise into the list while the client answers — `items`, `renderMessage`, `hasOlder`, `onLoadOlder`, `typing`. |
377
+ | [screen.tsx](examples/screen.tsx) | A support messenger as a work screen: the title and the unread count in its top bar, the dialogs with a search over them beside the conversation (one pane at a time on a phone), history that loads above as you scroll up, the unread band, reply and edit from the menu, the typing signal both ways, the request's events (an agent took it, its status moved, you joined) between the messages, unread counts from what was seen, and the Composer whose messages rise into the list while the client answers — `items`, `renderMessage`, `isService`, `hasOlder`, `onLoadOlder`, `typing`. |
344
378
 
345
379
  ## Mistakes
346
380
  - A header, a search bar and a scroller of your own around the messages → `Chat.Header`, `Chat.Search`, `Chat.Messages`: the chat wires typing, search, jumps and selection between them.
@@ -355,9 +389,11 @@ Chat.Root <div> column, fills its host; owns selection,
355
389
  - Marking messages read on arrival → `onRead` reports what was really on screen while the page was visible.
356
390
  - «печатает…» as a text you swap in the header and a row you add to the list → `Chat.Root typing`: both move together, smoothly.
357
391
  - Search results computed and scrolled to by hand → `Chat.Search`: it finds, counts, marks and brings matches into view, the focus staying in the field.
392
+ - A chat event as a `Message.Root` from «system», or a `Notification` → `Chat.Service`: a row of the list, outside the arrows, the selection and the search.
393
+ - A change written as «Лид → Арендатор» in the text → `Chat.ServiceChange from to`: the values keep their colours and assistive tech hears «с «Лид» на «Арендатор»».
358
394
  - `scrollIntoView` to show a pinned or quoted message → `Chat.Pinned` and `Message.Reply target` jump in the chat's own scroller and point at the message.
359
395
  - Checkboxes and selection handling of your own → `Chat.Root selected` with `Chat.SelectionBar`: click, Shift range and drag like DataTable rows.
360
396
 
361
397
  ## Related
362
- - **Built from:** [Message](../message/COMPONENT.md) (`Message.Typing`, the rows), [ScrollContainer](../scroll-container/COMPONENT.md) (the scroller, `anchor="end"`), [Input](../input/COMPONENT.md) (the search field), [Button](../button/COMPONENT.md) (search, steps, unpin, «к последним», the selection bar), [Badge](../badge/COMPONENT.md), [Crossfade](../crossfade/COMPONENT.md) (the status line, the first load), [Skeleton](../skeleton/COMPONENT.md) (the first load, older history), [VisuallyHidden](../visually-hidden/COMPONENT.md) (the loading announcement), Icon (`action.search`, `nav.chevronUp`, `nav.chevronDown`, `action.pin`, `action.close`); `useVirtualRows` (the window), `internal/messageList` (the contract between the list and its messages), `internal/readReceipts`, `internal/useRangeSelection` (selection by click, Shift and drag), `internal/messagePart` (the search query its texts mark)
398
+ - **Built from:** [Message](../message/COMPONENT.md) (`Message.Typing`, the rows), [ScrollContainer](../scroll-container/COMPONENT.md) (the scroller, `anchor="end"`), [Input](../input/COMPONENT.md) (the search field), [Button](../button/COMPONENT.md) (search, steps, unpin, «к последним», the selection bar), [Badge](../badge/COMPONENT.md), [Crossfade](../crossfade/COMPONENT.md) (the status line, the first load), [Skeleton](../skeleton/COMPONENT.md) (the first load, older history), [VisuallyHidden](../visually-hidden/COMPONENT.md) (the loading announcement, the phrase of a change), Icon (`action.search`, `nav.chevronUp`, `nav.chevronDown`, `nav.chevronRight`, `action.pin`, `action.close`); `useVirtualRows` (the window), `internal/messageList` (the contract between the list and its messages), `internal/readReceipts`, `internal/useRangeSelection` (selection by click, Shift and drag), `internal/messagePart` (the search query its texts mark)
363
399
  - **See also:** [Message](../message/COMPONENT.md) — one message and every content part; [Composer](../composer/COMPONENT.md) — the input in `Chat.Footer`; [Gallery](../gallery/COMPONENT.md), [VoicePlayer](../voice-player/COMPONENT.md), [VideoNote](../video-note/COMPONENT.md), [Timeline](../timeline/COMPONENT.md)