Components · Messages
Comment
Comments beside a document, Google Docs style — a margin of cards each level with the passage it is about, the card of one discussion, its comments, and the marker that replaces the margin on a narrow screen.
import { Comment } from "@botiverse/raft-ui/wip";The parts for comments on a document: CommentMargin lays out a column of CommentCards beside the document, each level with the passage it is about; CommentEntry is one comment in a card; CommentMarker replaces the margin where it doesn't fit. When and how a product shows comments is in Comments; this page covers the parts.
When to use
| Situation | Use |
|---|---|
| Comments on a document or on passages of it, in a viewer with room for a margin | CommentMargin with a CommentCard per discussion |
| The same comments where the margin doesn't fit | A CommentMarker beside each passage, opening a bottom sheet (Drawer) |
| A reply to a message in a conversation | A thread (ThreadPanel), not a comment |
| The number of comments on a file, on its card in a message | MessageAttachmentCommentSummary (MessageAttachment) |
Anatomy
CommentMargin: the column beside the document,position: relative, as tall as its lowest card. Required for the margin.CommentMarginItem: one card's place.topis where its anchor is, in px from the margin's top;activepins it there. Required for each card.
CommentCard: one discussion, onlayer-cardwith aline-hairlineborder andradius-container.activemarks the open one.CommentCardMeta: a line intext-captionandforeground-hintwith the caller's words: "2 replies", "On the whole document", "Position not found". Optional.CommentEntry: one comment. At least one.CommentEntryHeader: the author's Avatar atsize="xs",CommentEntryAuthor(text-subhead, bold) andCommentEntryTime(Space Mono,text-mono-meta,foreground-hint), a<time>.CommentEntryBody: the text, intext-callout.clampcuts it to 2 lines.
CommentCardQuote: the passage the discussion is about, in the caller's words, visually hidden (sr-only). Point the trigger's or the marker'saria-describedbyat it. Optional, but give every anchored discussion one.CommentCardTrigger: a button over the whole card that opens it. Optional; only on a collapsed card.- The reply box is a Composer (
ComposerRoot,Composer,ComposerInput,ComposerSubmit), not a part of its own.
CommentMarker: the button beside a passage on a narrow screen, with anAvatarGroupof up to 3size="2xs"avatars and the count.useCommentMarginFits(viewer, { minWidth }): whether the viewer has room for the margin (Wide and narrow).setCommentHighlights,COMMENT_HIGHLIGHT,COMMENT_HIGHLIGHT_ACTIVE: highlight the passages (Highlights).resolveCommentMarginLayout: the layoutCommentMarginuses, for a caller that places cards itself.
The body is text-callout, not text-body: a comment is an aside to the document, and the document's own text is text-body. On a desktop both are 14/22, so they read the same; on a phone a card is a step smaller (14/20) than the document (16/24), the step between a message and its preview (Typography). The header uses a message's styles (MessageItem): bold name, then the time in Space Mono.
States
| State | Look |
|---|---|
| Collapsed | The first comment, its text cut to 2 lines (clamp), and the number of replies in CommentCardMeta. CommentCardTrigger opens it |
| Active | active: a 1.5px line-strong border and shadow-float, lifted off the margin. Every comment, and the reply box when the person can reply |
| On the whole document | CommentCardMeta first, saying so. Its CommentMarginItem has top={0} |
| Position not found | CommentCardMeta first, saying the passage can no longer be found. Its CommentMarginItem has top={0}, after the whole document's |
Which parts a card holds is the caller's choice: raft-ui has no collapsed or expanded prop. Render clamp, the reply count and CommentCardTrigger for a collapsed card; every entry and the Composer for an open one.
Behavior
- Each card sits at its
top, level with the first line of its passage. When cards would overlap, they keep the document's order (bytop; items with the sametopkeep the order they were first rendered in) and each moves down to 8px (gap) under the card before it. - The active card stays at its
top, and the cards above it move up to make room, never above the margin's top; the cards below it move down. When the cards above don't fit, they stack from the top and the active card follows them. This is Google Docs' behavior: the discussion people are reading stays beside its passage. - The margin measures its cards with one
ResizeObserver, so a card that opens or gets a reply moves the ones below it. The resolved top is the item'sdata-top, applied astransform: translateY(), so a move doesn't lay the margin out again; it takes 150ms after the first placement, and none under reduced motion. The transform makes the item the containing block of anythingposition: fixedinside it: portal popups out of the card. topis the caller's measurement: the top of the passage's first line box (range.getClientRects()[0].top) minus the margin's top. Measure again when the document or the margin changes size, since the text rewraps.- Show a discussion whose passage can't be found in the document any more at the top, with the comments on the whole document, and say so in
CommentCardMeta; never drop it (Comments).
Wide and narrow
Cards in a margin and markers with a bottom sheet are different trees, so the switch between them is made in JavaScript, not with a container query. useCommentMarginFits(viewer) returns true only on a desktop, from 768px with a mouse or a trackpad ((width >= 48rem) and (pointer: fine), the query of every desktop metric), and while the viewer element, the one that holds the document and the margin, is at least 784px wide (minWidth). It watches the viewer with a ResizeObserver, so the layout changes when a sidebar or a thread opens beside it.
- Where it returns false, put a
CommentMarkerbeside each passage instead, in a 72pxCommentMargin(room for three avatars and the count) so markers don't overlap either, and open the discussion in a bottom sheet (Drawer). The marker at the top holds the comments on the whole document and those whose position is lost.
Highlights
The passages are highlighted with the CSS Custom Highlight API: setCommentHighlights(name, ranges) registers Ranges under a highlight name, and styles.css paints them. It never wraps the document's text in an element, so the document's DOM, its selection and its find in page stay as they were.
- Register every passage with comments under
COMMENT_HIGHLIGHT(raft-comment):brand-softwith a 2pxbrandunderline. - Register the active one under
COMMENT_HIGHLIGHT_ACTIVE(raft-comment-active) instead:brandwith ink type, in both modes, like a search hit. - Call the function it returns to clear the highlight. In a browser without
CSS.highlightsit does nothing, and the cards still sit beside their passages. - The names are global to the page: registering a name replaces what was registered under it. Let one viewer at a time own them, the one on screen, and clear them when it closes.
Content
- Write
CommentCardMetain sentence case, as a fact: "2 replies", "On the whole document", "Position not found". - Write the time as in a message header, in the person's 12-hour or 24-hour choice: 11:02 today, Yesterday 17:20 before that (Writing).
Accessibility
The parts are plain elements built with Base UI useRender; CommentMarker wraps Base UI Button.
- Render the margin as a landmark with a name, after the document in the DOM:
render={<aside aria-label="Comments" />}. Render its items in reading order: the whole document's, the lost ones, then by passage. The passages themselves can't be focused, so Tab leaves the document and goes through the cards (or the markers) top to bottom, and a screen reader's landmark list reaches them directly. - The highlights carry no semantics. Say which passage a card is about with
CommentCardQuote("On “Re-run the guard suites on staging”"), pointed at by thearia-describedbyofCommentCardTriggerorCommentMarker; for the whole document or a lost position, point it at theCommentCardMetathat says so as well. - Give
CommentCardTriggeranaria-labelthat says whose comment it opens ("Comment from Martin"); the card's text stays readable around it. - Opening a card removes its trigger, so move focus into the open card: into its reply box when it was opened from the keyboard (a click with
detail === 0), and to the card itself after a click (tabIndex={-1},focus({ preventScroll: true })), so Esc still reaches it. Esc closes the card and returns focus to its trigger. activesetsaria-current="true"on the card.- Give
CommentMarkeranaria-labelthat starts with the count it shows ("2 comments"). The bottom sheet it opens needs aDrawerTitle. - Give
CommentEntryTimethe moment indateTime(ISO 8601).
Rules
- Show nothing where there are no comments: no empty margin, no marker.
- Put each card level with its passage; never list anchored comments in a column of their own order.
- Open one discussion at a time; only the active card shows the reply box, so its send button is the one pink button (Button).
- Use the Composer for the reply box (Composer); don't build another input.
API
CommentCard
| Prop | Type | Default |
|---|---|---|
| The discussion people are reading or replying to: an ink border, lifted. | boolean | false |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
CommentCardMeta, CommentCardQuote, CommentCardTrigger, CommentEntry, CommentEntryAuthor
| Prop | Type | Default |
|---|---|---|
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
CommentEntryBody
| Prop | Type | Default |
|---|---|---|
| Cuts the text to 2 lines, for a collapsed card. | boolean | false |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
CommentEntryHeader, CommentEntryTime
| Prop | Type | Default |
|---|---|---|
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
CommentMargin
| Prop | Type | Default |
|---|---|---|
| Space between two cards that would otherwise touch, in px. Default 8. | number | 8 |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
CommentMarginItem
| Prop | Type | Default |
|---|---|---|
| Pins this card to its anchor and moves the others around it. | boolean | false |
| Where the card's anchor is: its y, in px, from the top of the margin. | number | |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
CommentMarker
| Prop | Type | Default |
|---|---|---|
| The marker of the discussion that is open. | boolean | false |
| Whether the button should be focusable when disabled. | boolean | false |
| Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). | boolean | true |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
Related
Changes
- 0.10.6 · AddedComments beside a document, as in Google Docs, through
@botiverse/raft-ui/wip:CommentMargin,CommentCard(withCommentCardTrigger,CommentCardMeta,CommentCardQuote),CommentEntryandCommentMarker. Cards sit level with their passages and keep the document's order; on a narrow viewer aCommentMarkerbeside each passage opens the discussion in a bottom sheet instead.useCommentMarginFits(viewer, { minWidth })picks between the two,setCommentHighlightswithCOMMENT_HIGHLIGHT/COMMENT_HIGHLIGHT_ACTIVEhighlights the passages, andresolveCommentMarginLayoutis the layout for callers that place cards themselves. New pattern: Comments.