@k2b/ui
Discussion
Portable notes and comments with compact authorship, Markdown composition, and progressive actions.
Compact notes and comments with Markdown composition, clear content hierarchy, optional reply context, and progressive item actions.
Notes
3- MK
Mara Klein Reply to Mara KleinMara Klein note actionsPurchase order requested from the customer. I added the reference to the invoice record.
- VK
Valentin Kolb Valentin Kolb note actionsReply to Mara KleinI will follow up before the payment deadline.
- AS
Alex Smith Alex Smith note actionsThe customer confirmed that the billing address is correct.
TSX
Copy<Discussion label="Notes" as="h2" surface="bare" count={3}> <Discussion.Composer label="Add note" placeholder="Add context for everyone with access…" submitLabel="Post note" onSubmit={postNote} /> <Discussion.List> <Discussion.Item author="Mara Klein" avatar={<Avatar name="Mara Klein" size="xs" />} timestamp={<time dateTime="2026-08-09T15:42:00Z">18 min ago</time>} actions={<IconButton label="Note actions">…</IconButton>} > <MarkdownView markdown="Purchase order requested from the customer." /> </Discussion.Item> <Discussion.Item author="Valentin Kolb" timestamp={…} replyContext="Reply to Mara Klein"> <MarkdownView markdown="I will follow up before the payment deadline." /> </Discussion.Item> </Discussion.List></Discussion>Discussion gives notes, comments, and short collaborative threads one portable visual and accessible structure. It owns the labelled surface, Markdown composer lifecycle, loading and retry presentation, automatic history loading, author alignment, metadata rhythm, and progressive item actions. The application owns storage, permissions, queries, mutations, author identity, and domain copy.
Import
import { Avatar, Discussion, MarkdownView } from "@k2b/ui";
import { For } from "solid-js";Use Discussion
Pass label, an optional icon, and a numeric count to Discussion. The label already names the domain, so the count renders as a number without repeating “comments” or “notes”. Pass the server-side total when pages are incomplete; omit the count while no accurate total is available.
The header is a detail panel section heading wherever the discussion appears: the same icon column with a muted icon, the same sentence-case title, the count chip immediately after the title (“None yet” for zero), and ghost actions in the section action color. Inside a DetailPanel the default surface becomes one of the panel's flat frames, the same surface and inset as a DetailPanel.Group, with no border. Place it directly in DetailPanel.Body; it needs no group or wrapper. Placed in a DetailPanel.Group it is one of the group's sections, and inside a section it adds no frame or inset of its own. Outside a detail panel the default surface stays a bordered card.
Use as="h2" when the discussion is a primary page section and surface="bare" when the host already supplies its surface, such as a paper card or a dialog. Header actions hold compact domain commands such as opening a conditional composer; use size="xs" buttons so the header keeps the section row height.
Use fold={3} on Discussion.List to show the newest three loaded entries.
A “Show n older” toggle sits above them and becomes “Show less” while expanded.
Earlier pages load only when the fold is expanded. Omit fold to show every
loaded entry. Use expanded and onExpandedChange to control the fold, for
example when an activity link reveals an older comment. The callback requests
a change; the supplied expanded value remains authoritative.
The composer field is a recessed well like the other inputs in a panel, not a bordered box. Discussion.Composer owns its initial Markdown draft, empty-value validation, pending state, Ctrl/Cmd+Enter submission, and submit button. Every onSubmit result except false accepts the submission and clears the draft. Returning false keeps it silently; throwing keeps it and shows the error inline until the draft changes, also when the user clears it. Mutation helpers that capture errors instead of throwing must therefore return false explicitly. Use initialValue and onCancel for an edit composer. onCancel only invokes the callback, so unmount the composer there when cancellation should discard its draft. Remount the composer when its resource identity changes so a draft cannot move to another record.
Discussion.List renders compact initial-loading and error rows. Render entries chronologically from oldest to newest and prepend each earlier page. With hasMore and onLoadMore, the list automatically loads earlier entries when its upper sentinel becomes visible. Return false when no page was applied; every other result tells the list to preserve the visible position after the prepend. It guards duplicate requests, and the small load action remains as an accessible fallback.
Each Discussion.Item receives author and body content plus optional avatar, timestamp, meta, replyContext, and actions. Timestamps end at the trailing text edge; item actions sit immediately before
them, after optional metadata. Item actions are progressive with a mouse or trackpad, including a tablet with a mouse, by default; use actionVisibility="always" only when they must remain persistently visible.
Do not add a second scroll container to the list. A surrounding DetailPanel.Body or page remains the single scroll owner. An empty list renders no placeholder; “None yet” in the discussion header and an available composer are sufficient.
Example
<Discussion label="Comments" icon="ti ti-messages" count={comments.total}>
<Discussion.Composer
label="Add comment"
placeholder="Write a comment in markdown…"
submitLabel="Post comment"
onSubmit={(body) => createComment(body)}
/>
<Discussion.List
loading={comments.loading}
loadingLabel="Loading comments"
error={comments.error}
onRetry={comments.retry}
hasMore={comments.hasMore}
loadingMore={comments.loadingMore}
loadMoreLabel="Load earlier comments"
onLoadMore={comments.loadMore}
>
<For each={comments.items}>
{(comment) => (
<Discussion.Item
avatar={<Avatar name={comment.authorName} size="xs" />}
author={comment.authorName}
timestamp={<time dateTime={comment.createdAt}>{comment.relativeTime}</time>}
meta={comment.edited ? "edited" : undefined}
actions={comment.actions}
>
<MarkdownView markdown={comment.body} headingScale="compact" />
</Discussion.Item>
)}
</For>
</Discussion.List>
</Discussion>Applications still decide whether the composer is present, map domain records to items, and enforce create, edit, or delete permissions. Discussion never receives domain records or authorization rules.
API reference
type DiscussionProps = Omit<JSX.HTMLAttributes<HTMLElement>, "children" | "class"> & {
label: JSX.Element; children: JSX.Element; icon?: string | false; count?: number; actions?: JSX.Element;
as?: "h2" | "h3"; surface?: "default" | "bare"; class?: string;
};
type DiscussionComposerProps = Omit<JSX.FormHTMLAttributes<HTMLFormElement>, "children" | "class" | "onSubmit"> & {
label: string; onSubmit: (message: string) => boolean | void | Promise<boolean | void>;
initialValue?: string; placeholder?: string; submitLabel?: string; cancelLabel?: string;
onCancel?: () => void; lines?: number; class?: string;
};
type DiscussionListProps = {
children?: JSX.Element; loading?: boolean; loadingLabel?: string; error?: string | null;
onRetry?: () => void | Promise<void>; hasMore?: boolean; loadingMore?: boolean; loadMoreLabel?: string;
onLoadMore?: () => boolean | void | Promise<boolean | void>; fold?: number;
expanded?: boolean; onExpandedChange?: (expanded: boolean) => void; class?: string;
};
type DiscussionItemProps = Omit<JSX.LiHTMLAttributes<HTMLLIElement>, "children" | "class"> & {
author: JSX.Element; children: JSX.Element; avatar?: JSX.Element; timestamp?: JSX.Element;
meta?: JSX.Element; replyContext?: JSX.Element; actions?: JSX.Element;
actionVisibility?: "always" | "progressive"; class?: string;
};These props map to Discussion, .Composer, .List, and .Item. JSX slots remain application content; asynchronous submit and pagination behavior is described above.
Accessibility
The root is a labelled section, the entries are an ordered list, and the composer is a native form. Loading and failures use status semantics, retry remains keyboard accessible, and automatic history loading has a manual fallback. Supply real <time dateTime> markup for timestamps and accessible names for every icon-only action. With any pointer that can hover, progressive item actions stay hidden until their row is hovered, keyboard-focused, or has its menu open. Touch-only screens always show them.
Runtime
The root, list, and existing items are server-renderable. Composer state and automatic history loading activate after mount. Applications should seed the first authorized page during SSR and use an owner-local query for subsequent pages.