@ahrowe/ui 0.49.0 → 0.50.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -0
  3. package/dist/cjs-types/common/chatComposer/chatComposer.d.ts +4 -0
  4. package/dist/cjs-types/common/chatComposer/chatComposer.types.d.ts +40 -0
  5. package/dist/cjs-types/common/chatComposer/index.d.ts +2 -0
  6. package/dist/cjs-types/common/chatMessage/chatMessage.d.ts +4 -0
  7. package/dist/cjs-types/common/chatMessage/chatMessage.types.d.ts +60 -0
  8. package/dist/cjs-types/common/chatMessage/index.d.ts +2 -0
  9. package/dist/cjs-types/common/chatMessageList/chatMessageList.d.ts +4 -0
  10. package/dist/cjs-types/common/chatMessageList/chatMessageList.types.d.ts +27 -0
  11. package/dist/cjs-types/common/chatMessageList/index.d.ts +2 -0
  12. package/dist/cjs-types/common/configProvider/configProvider.types.d.ts +6 -0
  13. package/dist/cjs-types/common/hooks/useAutoGrow.d.ts +12 -0
  14. package/dist/cjs-types/common/tabHeader/tabHeader.types.d.ts +5 -3
  15. package/dist/cjs-types/index.d.ts +6 -0
  16. package/dist/esm/common/chatComposer/chatComposer.mjs +2 -0
  17. package/dist/esm/common/chatComposer/chatComposer.mjs.map +1 -0
  18. package/dist/esm/common/chatComposer/chatComposer.module.mjs +2 -0
  19. package/dist/esm/common/chatComposer/chatComposer.module.mjs.map +1 -0
  20. package/dist/esm/common/chatMessage/chatMessage.mjs +2 -0
  21. package/dist/esm/common/chatMessage/chatMessage.mjs.map +1 -0
  22. package/dist/esm/common/chatMessage/chatMessage.module.mjs +2 -0
  23. package/dist/esm/common/chatMessage/chatMessage.module.mjs.map +1 -0
  24. package/dist/esm/common/chatMessage/chatMessage.types.mjs +2 -0
  25. package/dist/esm/common/chatMessage/chatMessage.types.mjs.map +1 -0
  26. package/dist/esm/common/chatMessageList/chatMessageList.mjs +2 -0
  27. package/dist/esm/common/chatMessageList/chatMessageList.mjs.map +1 -0
  28. package/dist/esm/common/chatMessageList/chatMessageList.module.mjs +2 -0
  29. package/dist/esm/common/chatMessageList/chatMessageList.module.mjs.map +1 -0
  30. package/dist/esm/common/dropdown/dropdown.mjs +1 -1
  31. package/dist/esm/common/hooks/useAutoGrow.mjs +2 -0
  32. package/dist/esm/common/hooks/useAutoGrow.mjs.map +1 -0
  33. package/dist/esm/common/infiniteBlock/useInfiniteScroll.mjs +1 -1
  34. package/dist/esm/common/infiniteBlock/useInfiniteScroll.mjs.map +1 -1
  35. package/dist/esm/common/input/input.mjs +1 -1
  36. package/dist/esm/common/input/input.mjs.map +1 -1
  37. package/dist/esm/common/tabHeader/tabHeader.mjs +1 -1
  38. package/dist/esm/common/tabHeader/tabHeader.mjs.map +1 -1
  39. package/dist/esm/index.mjs +1 -1
  40. package/dist/index.cjs +3 -3
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/style.css +1 -1
  43. package/dist/types/common/chatComposer/chatComposer.d.ts +4 -0
  44. package/dist/types/common/chatComposer/chatComposer.types.d.ts +40 -0
  45. package/dist/types/common/chatComposer/index.d.ts +2 -0
  46. package/dist/types/common/chatMessage/chatMessage.d.ts +4 -0
  47. package/dist/types/common/chatMessage/chatMessage.types.d.ts +60 -0
  48. package/dist/types/common/chatMessage/index.d.ts +2 -0
  49. package/dist/types/common/chatMessageList/chatMessageList.d.ts +4 -0
  50. package/dist/types/common/chatMessageList/chatMessageList.types.d.ts +27 -0
  51. package/dist/types/common/chatMessageList/index.d.ts +2 -0
  52. package/dist/types/common/configProvider/configProvider.types.d.ts +6 -0
  53. package/dist/types/common/hooks/useAutoGrow.d.ts +12 -0
  54. package/dist/types/common/tabHeader/tabHeader.types.d.ts +5 -3
  55. package/dist/types/index.d.ts +6 -0
  56. package/docs/CLAUDE.md +6 -3
  57. package/docs/ChatComposer.md +100 -0
  58. package/docs/ChatMessage.md +88 -0
  59. package/docs/ChatMessageList.md +113 -0
  60. package/docs/ConfigProvider.md +1 -0
  61. package/docs/TabHeader.md +19 -8
  62. package/package.json +1 -1
@@ -0,0 +1,4 @@
1
+ import { ReactElement } from 'react';
2
+ import { ChatComposerProps } from './chatComposer.types.js';
3
+ declare function ChatComposer(props: ChatComposerProps): ReactElement;
4
+ export default ChatComposer;
@@ -0,0 +1,40 @@
1
+ import { CSSProperties, ReactNode, Ref, TextareaHTMLAttributes } from 'react';
2
+ import { SlotClassNames, SlotStyles, DataProps } from '../types/slots.types.js';
3
+ export type ChatComposerSlots = 'input' | 'button';
4
+ /** Text ChatComposer renders of its own, for translating it. */
5
+ export interface ChatComposerLabels {
6
+ /** Shown in the empty field, and its accessible name. */
7
+ placeholder?: string;
8
+ /** Send button, read out by screen readers. */
9
+ send?: string;
10
+ /** Stop button shown while `onStop` is set, read out by screen readers. */
11
+ stop?: string;
12
+ }
13
+ /** Native attributes go to the `<textarea>`; `className` and `style` to the root. */
14
+ export interface ChatComposerProps extends DataProps, Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value' | 'defaultValue' | 'placeholder'> {
15
+ /** Called with the trimmed text. Not called while the field is empty or only whitespace. */
16
+ onSend: (text: string) => void;
17
+ /** Controlled value. Sending clears it through `onChange('')`. */
18
+ value?: string;
19
+ defaultValue?: string;
20
+ onChange?: (value: string) => void;
21
+ /** Runs before the composer's own handling; `preventDefault()` on an Enter keeps it from sending. */
22
+ onKeyDown?: TextareaHTMLAttributes<HTMLTextAreaElement>['onKeyDown'];
23
+ /** Swaps the send button for a stop button while set, e.g. while an AI answer streams. Enter sends nothing meanwhile. */
24
+ onStop?: () => void;
25
+ disabled?: boolean;
26
+ /** The height of the empty field, in lines. It grows from there to seven. Default 1. */
27
+ rows?: number;
28
+ /** Before the field: an attach or emoji button. Moves to a row below it once the text wraps. */
29
+ startContent?: ReactNode;
30
+ /** Between the field and the send button. Moves to a row below it, with the send button, once the text wraps. */
31
+ endContent?: ReactNode;
32
+ /** Ref to the inner `<textarea>`. */
33
+ ref?: Ref<HTMLTextAreaElement>;
34
+ /** Overrides the text ChatComposer renders of its own. Merged per key. */
35
+ labels?: ChatComposerLabels;
36
+ className?: string;
37
+ style?: CSSProperties;
38
+ classNames?: SlotClassNames<ChatComposerSlots>;
39
+ styles?: SlotStyles<ChatComposerSlots>;
40
+ }
@@ -0,0 +1,2 @@
1
+ export { default } from './chatComposer.js';
2
+ export * from './chatComposer.types.js';
@@ -0,0 +1,4 @@
1
+ import { ReactElement } from 'react';
2
+ import { ChatMessageProps } from './chatMessage.types.js';
3
+ declare function ChatMessage(props: ChatMessageProps): ReactElement;
4
+ export default ChatMessage;
@@ -0,0 +1,60 @@
1
+ import { CSSProperties, ReactNode } from 'react';
2
+ import { SlotClassNames, SlotStyles, HtmlProps } from '../types/slots.types.js';
3
+ import { ColorValue } from '../themeProvider/color.types.js';
4
+ /** How much the message stands out. Independent of its colour. */
5
+ export declare enum ChatMessageStyleType {
6
+ /** Solid bubble, text in a contrasting colour. The default for `isOwn`. */
7
+ Filled = "filled",
8
+ /** A light tint of the colour. The default for everyone else. */
9
+ Light = "light",
10
+ /** No bubble, full width: an AI assistant's answer. */
11
+ Plain = "plain"
12
+ }
13
+ /** Delivery state of an own message: an icon next to `time`, and `Failed` a notice under the bubble. */
14
+ export declare enum ChatMessageStatus {
15
+ Sending = "sending",
16
+ Sent = "sent",
17
+ Delivered = "delivered",
18
+ Read = "read",
19
+ Failed = "failed"
20
+ }
21
+ export type ChatMessageSlots = 'avatar' | 'body' | 'author' | 'bubble' | 'meta' | 'actions';
22
+ /** Text ChatMessage renders of its own, for translating it. */
23
+ export interface ChatMessageLabels {
24
+ /** Read out for the status icon. */
25
+ sending?: string;
26
+ sent?: string;
27
+ delivered?: string;
28
+ read?: string;
29
+ /** Shown under the bubble. */
30
+ failed?: string;
31
+ /** Names an own message without `author` for screen readers. */
32
+ you?: string;
33
+ }
34
+ export interface ChatMessageProps extends HtmlProps<HTMLDivElement> {
35
+ /** The message. A string keeps its line breaks; anything else (rendered Markdown, an image) renders as given. */
36
+ children?: ReactNode;
37
+ /** Sent by the current user: aligned to the end, `Filled` in `Primary` by default. */
38
+ isOwn?: boolean;
39
+ /** Follows a message from the same author: hides `author` from view, keeps the space of `avatar` and tightens the gap above. */
40
+ isContinued?: boolean;
41
+ /** Shown beside the bubble, typically an `Avatar`. */
42
+ avatar?: ReactNode;
43
+ /** Name above the bubble. Screen readers get it on every message of a run, not only the first. */
44
+ author?: ReactNode;
45
+ /** Shown in the bubble's corner. Format it yourself, e.g. with `Intl.DateTimeFormat`. */
46
+ time?: ReactNode;
47
+ status?: ChatMessageStatus;
48
+ /** Shown below the bubble, after the `Failed` notice: copy, retry, rate. */
49
+ actions?: ReactNode;
50
+ /** Default `Filled` when `isOwn`, otherwise `Light`. */
51
+ styleType?: ChatMessageStyleType;
52
+ /** A `ThemeColor` or any CSS colour. Default `Primary` when `isOwn`, otherwise `Neutral`. */
53
+ color?: ColorValue;
54
+ /** Overrides the text ChatMessage renders of its own. Merged per key. */
55
+ labels?: ChatMessageLabels;
56
+ className?: string;
57
+ style?: CSSProperties;
58
+ classNames?: SlotClassNames<ChatMessageSlots>;
59
+ styles?: SlotStyles<ChatMessageSlots>;
60
+ }
@@ -0,0 +1,2 @@
1
+ export { default } from './chatMessage.js';
2
+ export * from './chatMessage.types.js';
@@ -0,0 +1,4 @@
1
+ import { ReactElement } from 'react';
2
+ import { ChatMessageListProps } from './chatMessageList.types.js';
3
+ declare function ChatMessageList(props: ChatMessageListProps): ReactElement;
4
+ export default ChatMessageList;
@@ -0,0 +1,27 @@
1
+ import { CSSProperties, ReactNode, Ref } from 'react';
2
+ import { SlotClassNames, SlotStyles, HtmlProps } from '../types/slots.types.js';
3
+ export type ChatMessageListSlots = 'viewport' | 'content' | 'scrollButton';
4
+ /** Text ChatMessageList renders of its own, for translating it. */
5
+ export interface ChatMessageListLabels {
6
+ /** Accessible name of the message log. */
7
+ messages?: string;
8
+ /** Button that jumps back to the newest message, read out by screen readers. */
9
+ scrollToBottom?: string;
10
+ }
11
+ export interface ChatMessageListHandle {
12
+ /** Scrolls to the newest message and follows new ones again. Call it after the user sends one. */
13
+ scrollToBottom: () => void;
14
+ }
15
+ export interface ChatMessageListProps extends HtmlProps<HTMLDivElement> {
16
+ /** `ChatMessage`s, and anything between them: a `Divider` with a date, a typing indicator. Oldest first. */
17
+ children?: ReactNode;
18
+ /** Loads older messages when the user scrolls to the top. Prepend them; the view stays where it was. Leave it out once there are no more. */
19
+ onLoadMore?: () => Promise<void>;
20
+ ref?: Ref<ChatMessageListHandle>;
21
+ /** Overrides the text ChatMessageList renders of its own. Merged per key. */
22
+ labels?: ChatMessageListLabels;
23
+ className?: string;
24
+ style?: CSSProperties;
25
+ classNames?: SlotClassNames<ChatMessageListSlots>;
26
+ styles?: SlotStyles<ChatMessageListSlots>;
27
+ }
@@ -0,0 +1,2 @@
1
+ export { default } from './chatMessageList.js';
2
+ export * from './chatMessageList.types.js';
@@ -49,6 +49,9 @@ import { RoomViewerProps } from '../roomViewer/roomViewer.types.js';
49
49
  import { PopoverProps } from '../popover/popover.types.js';
50
50
  import { ModalProps } from '../modal/modal.types.js';
51
51
  import { StepperProps } from '../stepper/stepper.types.js';
52
+ import { ChatMessageProps } from '../chatMessage/chatMessage.types.js';
53
+ import { ChatMessageListProps } from '../chatMessageList/chatMessageList.types.js';
54
+ import { ChatComposerProps } from '../chatComposer/chatComposer.types.js';
52
55
  import { Presentation } from '../configProvider/presentation.types.js';
53
56
  /**
54
57
  * Global default props keyed by component name.
@@ -133,6 +136,9 @@ export interface ComponentDefaults {
133
136
  CommandPalette?: Partial<CommandPaletteProps>;
134
137
  WeatherIcon?: Partial<WeatherIconProps>;
135
138
  Stepper?: Partial<StepperProps>;
139
+ ChatMessage?: Partial<ChatMessageProps>;
140
+ ChatMessageList?: Partial<ChatMessageListProps>;
141
+ ChatComposer?: Partial<ChatComposerProps>;
136
142
  }
137
143
  export interface ConfigProviderProps {
138
144
  children: React.ReactNode;
@@ -0,0 +1,12 @@
1
+ import { RefObject } from 'react';
2
+ interface AutoGrowOptions {
3
+ enabled?: boolean;
4
+ /** Whether the text needs more than the field's `rows`, after each change of `value` or width. */
5
+ onGrow?: (isGrown: boolean) => void;
6
+ }
7
+ /**
8
+ * Keeps a textarea as tall as its text, up to the `max-height` its stylesheet sets. It measures
9
+ * again when `value` changes, so the field must be controlled, and when its width changes.
10
+ */
11
+ export declare function useAutoGrow(ref: RefObject<HTMLElement | null>, value: unknown, { enabled, onGrow }?: AutoGrowOptions): void;
12
+ export {};
@@ -1,12 +1,14 @@
1
1
  import { default as React } from 'react';
2
2
  import { IconContent } from '../types/icon.types.js';
3
3
  import { SlotClassNames, SlotStyles, HtmlProps } from '../types/slots.types.js';
4
- export type TabHeaderSlots = 'root' | 'tab' | 'icon' | 'labels' | 'title' | 'subtitle';
4
+ export type TabHeaderSlots = 'root' | 'tab' | 'icon' | 'labels' | 'title' | 'subtitle' | 'endContent';
5
5
  export interface TabHeaderTab {
6
6
  id: string;
7
7
  icon: IconContent;
8
- title?: string;
9
- subtitle?: string;
8
+ title?: React.ReactNode;
9
+ subtitle?: React.ReactNode;
10
+ /** After the labels. Stays visible when a narrow screen collapses the tab, so keep it small: a `Badge`, a dot. */
11
+ endContent?: React.ReactNode;
10
12
  disabled?: boolean;
11
13
  }
12
14
  export interface TabHeaderProps extends Omit<HtmlProps, 'onChange'> {
@@ -29,6 +29,12 @@ export { default as Card } from './common/card/index.js';
29
29
  export * from './common/card/index.js';
30
30
  export { default as Carousel } from './common/carousel/index.js';
31
31
  export * from './common/carousel/index.js';
32
+ export { default as ChatComposer } from './common/chatComposer/index.js';
33
+ export * from './common/chatComposer/index.js';
34
+ export { default as ChatMessage } from './common/chatMessage/index.js';
35
+ export * from './common/chatMessage/index.js';
36
+ export { default as ChatMessageList } from './common/chatMessageList/index.js';
37
+ export * from './common/chatMessageList/index.js';
32
38
  export { default as Checkbox } from './common/checkbox/index.js';
33
39
  export * from './common/checkbox/index.js';
34
40
  export { default as Chip } from './common/chip/index.js';
package/docs/CLAUDE.md CHANGED
@@ -41,9 +41,9 @@ enums and slot names. Do not infer props from a component's name.
41
41
  `Light` for status and selection, `Outlined` for secondary actions, `Plain` for tertiary ones.
42
42
  - **`icon` props** take a FontAwesome `IconDefinition` or any React element.
43
43
  - **`ref`** is a plain prop, there is no `forwardRef`. It lands on the element you would reach for:
44
- Input, Textarea, NumberInput and SearchInput hand out their inner `<input>`. Timer, Flip, Tilt,
45
- Ripple, VirtualList, RoomDrawer and RoomViewer expose an imperative handle instead; its type is
46
- exported next to the props.
44
+ Input, Textarea, NumberInput and SearchInput hand out their inner `<input>`, ChatComposer its
45
+ `<textarea>`. Timer, Flip, Tilt, Ripple, VirtualList, ChatMessageList, RoomDrawer and RoomViewer
46
+ expose an imperative handle instead; its type is exported next to the props.
47
47
  - Every module is `'use client'`; nothing renders on the server. The exception is
48
48
  `@ahrowe/ui/floor-plan`, the plan model (FloorPlan.md) without React, for server code that reads
49
49
  or validates a plan. No right-to-left support.
@@ -76,6 +76,9 @@ enums and slot names. Do not infer props from a component's name.
76
76
  | Calendar.md | Calendar, CalendarView | Showing and editing your own events over time, in month, week, day and agenda views: bookings, shifts, appointments |
77
77
  | Card.md | Card, CardStyleType, CardHeader, CardMedia, CardContent, CardActions, ThemeColor | Surface container for grouping related content — list items, profile cards, dashboard tiles, product listings |
78
78
  | Carousel.md | Carousel | A slide track for stepping through a set of items one at a time, with arrows, dots, and drag/swipe support, plus optional autoplay |
79
+ | ChatComposer.md | ChatComposer | The field a chat message is written in, with its send button: under a ChatMessageList, which shows a whole chat put together |
80
+ | ChatMessage.md | ChatMessage, ChatMessageStyleType, ChatMessageStatus | One message in a conversation: a messenger bubble, a support agent's reply, an AI assistant's answer |
81
+ | ChatMessageList.md | ChatMessageList | The scrolling conversation of a chat: a messenger, a support widget, an AI assistant |
79
82
  | Checkbox.md | Checkbox | Boolean toggle input — settings, multi-select lists, accept terms, feature flags |
80
83
  | Chip.md | Chip, ChipContainer, ChipStyleType, ThemeColor | Compact tag, label, or filter token — selected items, applied filters, category badges, user avatars with remove |
81
84
  | ColorPicker.md | ColorPicker | Hex colour selection — theme editors, annotation tools, customisation panels |
@@ -0,0 +1,100 @@
1
+ # ChatComposer
2
+
3
+ **When to use:** The field a chat message is written in, with its send button: under a [ChatMessageList](ChatMessageList.md), which shows a whole chat put together. For multiline text in a form, with a label and validation, use [Textarea](Textarea.md).
4
+
5
+ **Keywords:** message input, chat input, prompt, compose, reply box, enter to send, submit, attachment
6
+
7
+ **Import:** `import { ChatComposer } from '@ahrowe/ui'`
8
+ **Types:** `import type { ChatComposerProps, ChatComposerLabels } from '@ahrowe/ui'`
9
+
10
+ ```tsx
11
+ import { ActionIcon, ChatComposer } from '@ahrowe/ui';
12
+ import { faFaceSmile, faPaperclip } from '@fortawesome/free-solid-svg-icons';
13
+
14
+ // Uncontrolled: it clears itself after sending
15
+ <ChatComposer onSend={(text) => send(text)} />
16
+
17
+ // AI chat: a stop button replaces send while the answer streams
18
+ <ChatComposer
19
+ onSend={ask}
20
+ onStop={isStreaming ? stop : undefined}
21
+ labels={{ placeholder: 'Ask anything' }}
22
+ />
23
+
24
+ // Buttons of your own around the field
25
+ <ChatComposer
26
+ onSend={send}
27
+ startContent={<ActionIcon icon={faPaperclip} aria-label='Attach a file' onClick={openFilePicker} />}
28
+ endContent={<ActionIcon icon={faFaceSmile} aria-label='Emoji' onClick={openEmojiPicker} />}
29
+ />
30
+
31
+ // Controlled, e.g. to keep a draft per conversation: sending clears it through onChange('')
32
+ <ChatComposer value={draft} onChange={setDraft} onSend={send} />
33
+
34
+ // Native attributes go to the textarea; an Enter your onKeyDown prevents is not sent
35
+ <ChatComposer
36
+ onSend={send}
37
+ maxLength={2000}
38
+ autoFocus
39
+ onKeyDown={(event) => {
40
+ if (event.key === 'Enter' && isMentionListOpen) {
41
+ event.preventDefault();
42
+ pickMention();
43
+ }
44
+ }}
45
+ />
46
+ ```
47
+
48
+ **Keys:**
49
+
50
+ | Key | Does |
51
+ |-----|------|
52
+ | `Enter` | Sends. On a touch screen it starts a new line instead, since that keyboard has no other way to, and only the button sends |
53
+ | `Shift+Enter` | A new line |
54
+ | `Ctrl+Enter` / `⌘+Enter` | Sends, on a touch screen too |
55
+
56
+ The `Enter` that confirms a word in a Japanese, Chinese or Korean input method never sends,
57
+ including in Safari, which reports it as an ordinary `Enter`.
58
+
59
+ `onSend` gets the text trimmed, and is not called while the field is empty or only whitespace;
60
+ the send button is disabled then too. The focus stays in the field after sending, so a phone
61
+ keyboard stays open. The field grows with the text up to seven lines, then scrolls, and measures
62
+ again when its width changes.
63
+
64
+ **Buttons of your own.** `startContent` and `endContent` sit beside the field while the text fits
65
+ in it. Once it needs more lines than `rows` (a second one, by default), the field takes the whole
66
+ width and the buttons move to a row below it: `startContent` at the start, `endContent` and the
67
+ send button at the end, the usual toolbar of an AI chat. So several of them never leave the field
68
+ too narrow to write in. They move back once the field is empty, usually after sending, rather
69
+ than as soon as the wider field fits the text on one line, which would wrap it again. With only
70
+ the send button there is nothing to move, and it stays beside the field.
71
+
72
+ While `onStop` is set the send button is a stop button that calls it, and `Enter` sends nothing,
73
+ so the user can type the next message while an answer streams in.
74
+
75
+ **Key props:**
76
+
77
+ | Prop | Type | Description |
78
+ |------|------|-------------|
79
+ | `onSend` | `(text: string) => void` | Called with the trimmed text (required) |
80
+ | `value` | `string` | Controlled value. Sending clears it through `onChange('')` |
81
+ | `defaultValue` | `string` | Initial value, uncontrolled |
82
+ | `onChange` | `(value: string) => void` | Every change |
83
+ | `onStop` | `() => void` | Swaps send for a stop button while set |
84
+ | `onKeyDown` | `KeyboardEventHandler` | Runs before the composer's own keys; `preventDefault()` on an Enter keeps it from sending |
85
+ | `disabled` | `boolean` | Disables the field and both buttons |
86
+ | `rows` | `number` | The empty field's height in lines, default 1. It grows from there to seven |
87
+ | `startContent` | `ReactNode` | Before the field: attach, emoji. Below it once the text wraps, see above |
88
+ | `endContent` | `ReactNode` | Between the field and the send button. Below it once the text wraps |
89
+ | `ref` | `Ref<HTMLTextAreaElement>` | Ref to the inner `<textarea>`, e.g. to focus it when a conversation opens |
90
+
91
+ Every other native attribute (`maxLength`, `autoFocus`, `name`, `spellCheck`, `onPaste`, `aria-*`,
92
+ `data-*`) goes to the `<textarea>`, as on [Input](Input.md); `className` and `style` go to the
93
+ root. An `aria-label` replaces the placeholder as the field's name, and `readOnly` also keeps it
94
+ from sending.
95
+
96
+ **Global defaults:** adopts `ConfigProvider`. See [ConfigProvider.md](ConfigProvider.md).
97
+
98
+ **Slots:** `input` (the `<textarea>`) `button` (send or stop)
99
+
100
+ **Labels:** `placeholder` (also the field's accessible name) `send` `stop`: the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `ChatComposerLabels`.
@@ -0,0 +1,88 @@
1
+ # ChatMessage
2
+
3
+ **When to use:** One message in a conversation: a messenger bubble, a support agent's reply, an AI assistant's answer. Put them in a [ChatMessageList](ChatMessageList.md), which also shows a whole chat put together. For a note between messages ("Lisa joined", a date), use a [Divider](Divider.md) with `text`.
4
+
5
+ **Keywords:** speech bubble, messenger, whatsapp, chatbot, assistant, LLM, reply, read receipt, seen, ticks, typing indicator
6
+
7
+ **Import:** `import { ChatMessage, ChatMessageStyleType, ChatMessageStatus } from '@ahrowe/ui'`
8
+ **Types:** `import type { ChatMessageProps, ChatMessageLabels } from '@ahrowe/ui'`
9
+
10
+ ```tsx
11
+ import { Avatar, ChatMessage, ChatMessageStatus, ChatMessageStyleType, CopyButton, Loader, LoaderType, ThemeColor } from '@ahrowe/ui';
12
+
13
+ // Messenger: two from Ava in a run, then your own with its delivery state
14
+ <ChatMessage author='Ava' avatar={<Avatar name='Ava' />} time='10:41'>Are we still on for tonight?</ChatMessage>
15
+ <ChatMessage author='Ava' avatar={<Avatar name='Ava' />} time='10:41' isContinued>8 o'clock?</ChatMessage>
16
+ <ChatMessage isOwn time='10:42' status={ChatMessageStatus.Read}>Yes, see you there</ChatMessage>
17
+
18
+ // AI assistant: no bubble, full width, actions underneath
19
+ <ChatMessage styleType={ChatMessageStyleType.Plain} actions={<CopyButton value={answer} />}>
20
+ <Markdown>{answer}</Markdown>
21
+ </ChatMessage>
22
+
23
+ // Someone typing, or an answer on its way: a Loader is the content
24
+ <ChatMessage avatar={<Avatar name='Ava' />}>
25
+ <Loader type={LoaderType.Dots} size='1em' color={ThemeColor.Neutral} labels={{ loading: 'Ava is typing' }} />
26
+ </ChatMessage>
27
+ ```
28
+
29
+ **Enums:**
30
+
31
+ - `ChatMessageStyleType`: `Filled` (default when `isOwn`) | `Light` (default otherwise) | `Plain`, see [ThemeColor.md](ThemeColor.md). A `Light` bubble keeps the page's text colour, as a `Light` Card does. `Plain` has no bubble and takes the full width, for an assistant's answer
32
+ - `ChatMessageStatus`: `Sending` (a clock) | `Sent` (a check) | `Delivered` (two checks) | `Read` (two checks at full strength, in `--info-color` except on a `Filled` bubble, where the rest are dimmed) | `Failed` (no icon in the bubble: "Not sent" under it, ahead of `actions`, where a retry button belongs, with the icon in `--error-color` and the text in the page's own)
33
+
34
+ **A run of messages.** Set `isContinued` on every message after the first from the same author:
35
+ it hides `author` from view, tightens the gap above, rounds off the corner that points at the
36
+ author, and keeps the space of `avatar` so the run stays aligned. Pass the same `author` and
37
+ `avatar` to all of them: screen readers still read `author` on each one. Start a new run after a
38
+ date `Divider`, or the first message under it shows no name.
39
+
40
+ **Who sent it, for screen readers.** Alignment and colour are not read out, so each message names
41
+ its sender: `author`, or "You" (`labels.you`, never shown) on an own message without one. In a
42
+ one-to-one chat that is enough to leave `author` out; in a group, pass it to everyone else's.
43
+
44
+ ```tsx
45
+ {messages.map((message, index) => (
46
+ <ChatMessage
47
+ key={message.id}
48
+ isOwn={message.authorId === me.id}
49
+ isContinued={messages[index - 1]?.authorId === message.authorId}
50
+ author={message.authorId === me.id ? undefined : message.authorName}
51
+ avatar={message.authorId === me.id ? undefined : <Avatar name={message.authorName} src={message.avatarUrl} />}
52
+ time={timeFormat.format(message.sentAt)}
53
+ status={message.status}
54
+ >
55
+ {message.text}
56
+ </ChatMessage>
57
+ ))}
58
+ ```
59
+
60
+ **Content.** A string keeps its line breaks, so text typed with Shift+Enter shows as typed. Any
61
+ other content renders as given: the library renders no Markdown, so pass your renderer's output
62
+ (`react-markdown`, `marked`). A long word or URL wraps rather than widening the bubble. `time` and
63
+ `status` sit at the bubble's end: on the same line as a short message, on a line of their own
64
+ under a long one.
65
+
66
+ **Key props:**
67
+
68
+ | Prop | Type | Description |
69
+ |------|------|-------------|
70
+ | `children` | `ReactNode` | The message |
71
+ | `isOwn` | `boolean` | Sent by the current user: aligned to the end, `Filled` in `Primary` by default |
72
+ | `isContinued` | `boolean` | Follows a message from the same author, see above |
73
+ | `avatar` | `ReactNode` | Beside the bubble, typically an [Avatar](Avatar.md) |
74
+ | `author` | `ReactNode` | Name above the bubble, on the first message of a run. Screen readers get it on every one |
75
+ | `time` | `ReactNode` | In the bubble's corner. Format it yourself, e.g. with `Intl.DateTimeFormat` |
76
+ | `status` | `ChatMessageStatus` | Delivery state, an icon after `time` that screen readers read out. `Failed` is a notice under the bubble instead |
77
+ | `actions` | `ReactNode` | Under the bubble, after the `Failed` notice: a [CopyButton](CopyButton.md), retry, thumbs up and down |
78
+ | `styleType` | `ChatMessageStyleType` | See above |
79
+ | `color` | `ColorValue` | A `ThemeColor` or any CSS colour. Default `Primary` when `isOwn`, otherwise `Neutral` |
80
+
81
+ The bubble is at most 80% of the list's width (40em at most); `styles={{ body: { maxWidth } }}`
82
+ changes that.
83
+
84
+ **Global defaults:** adopts `ConfigProvider`. See [ConfigProvider.md](ConfigProvider.md).
85
+
86
+ **Slots:** `avatar` `body` (the column holding author, bubble and actions) `author` `bubble` `meta` (time and status) `actions` (the row under the bubble, with the `Failed` notice)
87
+
88
+ **Labels:** `sending` `sent` `delivered` `read` (the names screen readers give the status icon), `failed` (the notice under a failed message) and `you` (an own message's sender, for screen readers only). Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `ChatMessageLabels`.
@@ -0,0 +1,113 @@
1
+ # ChatMessageList
2
+
3
+ **When to use:** The scrolling conversation of a chat: a messenger, a support widget, an AI assistant. It opens at the newest message, follows new ones while the user is at the bottom, and loads older ones at the top without moving the view. Fill it with [ChatMessage](ChatMessage.md)s and put a [ChatComposer](ChatComposer.md) under it. For a long list that is not a conversation, use [VirtualList](VirtualList.md).
4
+
5
+ **Keywords:** messenger, whatsapp, support chat, chatbot, assistant, LLM, history, thread, stick to bottom, autoscroll, infinite scroll, load older, jump to latest, unread
6
+
7
+ **Import:** `import { ChatMessageList } from '@ahrowe/ui'`
8
+ **Types:** `import type { ChatMessageListProps, ChatMessageListHandle, ChatMessageListLabels } from '@ahrowe/ui'`
9
+
10
+ ## A whole chat
11
+
12
+ ```tsx
13
+ import { useRef, useState } from 'react';
14
+ import { Avatar, ChatComposer, ChatMessage, ChatMessageList, ChatMessageStatus, Divider } from '@ahrowe/ui';
15
+ import type { ChatMessageListHandle } from '@ahrowe/ui';
16
+
17
+ function Chat({ me, initial }: ChatProps) {
18
+ const [messages, setMessages] = useState<Message[]>(initial);
19
+ const [hasOlder, setHasOlder] = useState(true);
20
+ const listRef = useRef<ChatMessageListHandle>(null);
21
+
22
+ const loadOlder = async () => {
23
+ const page = await api.messagesBefore(messages[0].id);
24
+ setMessages((current) => [...page.messages, ...current]);
25
+ setHasOlder(page.hasMore);
26
+ };
27
+
28
+ const send = (text: string) => {
29
+ setMessages((current) => [...current, { id: crypto.randomUUID(), authorId: me.id, text, status: ChatMessageStatus.Sending }]);
30
+ // Sending from further up the history takes the user down to their message.
31
+ listRef.current?.scrollToBottom();
32
+ };
33
+
34
+ return (
35
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100dvh' }}>
36
+ <ChatMessageList ref={listRef} style={{ flex: 1 }} onLoadMore={hasOlder ? loadOlder : undefined}>
37
+ <Divider text='Today' />
38
+ {messages.map((message, index) => (
39
+ <ChatMessage
40
+ key={message.id}
41
+ isOwn={message.authorId === me.id}
42
+ isContinued={messages[index - 1]?.authorId === message.authorId}
43
+ avatar={message.authorId === me.id ? undefined : <Avatar name={message.authorName} />}
44
+ author={message.authorId === me.id ? undefined : message.authorName}
45
+ status={message.status}
46
+ >
47
+ {message.text}
48
+ </ChatMessage>
49
+ ))}
50
+ </ChatMessageList>
51
+ <ChatComposer onSend={send} />
52
+ </div>
53
+ );
54
+ }
55
+ ```
56
+
57
+ An AI chat is the same with a `Plain` answer that grows as it streams, and the composer's stop
58
+ button while it does:
59
+
60
+ ```tsx
61
+ <ChatMessageList ref={listRef} style={{ flex: 1 }}>
62
+ {turns.map((turn) =>
63
+ turn.role === 'user' ? (
64
+ <ChatMessage key={turn.id} isOwn>{turn.text}</ChatMessage>
65
+ ) : (
66
+ <ChatMessage key={turn.id} styleType={ChatMessageStyleType.Plain} actions={<CopyButton value={turn.text} />}>
67
+ <Markdown>{turn.text}</Markdown>
68
+ </ChatMessage>
69
+ ),
70
+ )}
71
+ </ChatMessageList>
72
+ <ChatComposer onSend={ask} onStop={isStreaming ? stop : undefined} />
73
+ ```
74
+
75
+ ## Scrolling
76
+
77
+ - **Following.** While the user is within 32px of the bottom, anything that makes the content
78
+ taller keeps the newest message in view: a new message, an answer streaming in, an image
79
+ loading, the list getting shorter because the composer grew or a phone keyboard opened.
80
+ - **Reading back.** As soon as the user scrolls up, however little, nothing moves under them: new
81
+ messages arrive below, and a round button appears in the corner that jumps back down and starts
82
+ following again. It turns `Primary` once a new message has arrived below. Scrolling back to the
83
+ bottom by hand does the same.
84
+ - **After sending**, call `scrollToBottom()` on the handle, or a user who sent from further up
85
+ never sees their own message arrive.
86
+ - **Older messages.** With `onLoadMore`, reaching the top calls it and shows a loader there.
87
+ Prepend what it loads; the message that was at the top stays exactly where it was, at the very
88
+ top too, where the browser's own scroll anchoring does nothing and Safari has none at all. It is
89
+ called again while the loaded page still leaves the top in view. If it rejects, it is called
90
+ again after three seconds if the top is still in view, or whenever the user next scrolls there.
91
+ Pass `undefined` once there is nothing older.
92
+
93
+ **Sizing.** The list scrolls itself and fills the height it is given: `flex: 1` in a column, as
94
+ above, or a `height`. A conversation shorter than the list sits at the bottom, by the composer. To
95
+ start at the top instead, as an AI chat often does: `styles={{ content: { marginTop: 0 } }}`.
96
+
97
+ **Key props:**
98
+
99
+ | Prop | Type | Description |
100
+ |------|------|-------------|
101
+ | `children` | `ReactNode` | Messages, oldest first, and anything between them: a `Divider` with a date, a typing indicator |
102
+ | `onLoadMore` | `() => Promise<void>` | Loads older messages when the top comes into view, see above |
103
+ | `ref` | `Ref<ChatMessageListHandle>` | `scrollToBottom()`: back to the newest message, smoothly unless the user prefers reduced motion, and following again |
104
+
105
+ **Accessibility:** the scrolling element is a `role="log"`, which screen readers announce
106
+ politely as messages are added, named "Messages" (`labels.messages`). It is a tab stop of its own,
107
+ so the history can be scrolled from the keyboard, and `aria-busy` while older messages load.
108
+
109
+ **Global defaults:** adopts `ConfigProvider`. See [ConfigProvider.md](ConfigProvider.md).
110
+
111
+ **Slots:** `viewport` (the scrolling log) `content` (the column of messages inside it) `scrollButton`
112
+
113
+ **Labels:** `messages` `scrollToBottom`: the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `ChatMessageListLabels`.
@@ -97,6 +97,7 @@ Each entry is a `Partial<...Props>`, so any of that component's props can be def
97
97
  - **Form inputs:** `Input` · `Textarea` · `NumberInput` · `Dropdown` · `InputDropdown` · `Checkbox` · `Switch` · `RadioGroup` · `DatePicker` · `OptionPicker` · `OtpInput`
98
98
  - **Display:** `Button` · `CopyButton` · `ActionButtons` · `Badge` · `Chip` · `Card` · `SectionHeader` · `Skeleton` · `Loader` · `LoadingOverlay` · `Accordion` · `Divider` · `Timer` · `WeatherIcon` · `Stepper`
99
99
  - **Overlays:** `ConfirmModal` · `Modal` · `CommandPalette` · `Popover`
100
+ - **Chat:** `ChatMessage` · `ChatMessageList` · `ChatComposer`
100
101
  - **Editors:** `RoomDrawer`, `RoomViewer`
101
102
  - **Scheduling:** `Calendar`, for `labels`, `locale`, `weekStartsOn`, `slotMinutes`, `views`, `hiddenDays`, `scrollToHour`, `workingHours` and `showWeekNumbers` only (see below)
102
103
 
package/docs/TabHeader.md CHANGED
@@ -1,18 +1,18 @@
1
1
  # TabHeader
2
2
 
3
- **When to use:** A horizontal row of tabs to switch between sibling views or sections — settings panels, dashboards, detail pages. Each tab carries an icon plus an optional title and subtitle.
3
+ **When to use:** A horizontal row of tabs to switch between sibling views or sections — settings panels, dashboards, detail pages. Each tab carries an icon plus an optional title, subtitle and end content such as a badge.
4
4
 
5
- **Keywords:** tabs navigation, tab bar
5
+ **Keywords:** tabs navigation, tab bar, pill, counter
6
6
 
7
7
  **Import:** `import { TabHeader } from '@ahrowe/ui'`
8
8
 
9
- **Mobile behaviour:** On narrow screens (≤ 640px) only the active tab keeps its title and subtitle; every other tab collapses to just its icon.
9
+ **Mobile behaviour:** On narrow screens (≤ 640px) only the active tab keeps its title and subtitle; every other tab collapses to its icon and `endContent`.
10
10
 
11
11
  ```tsx
12
12
  import { useState } from 'react';
13
- import { TabHeader } from '@ahrowe/ui';
13
+ import { TabHeader, Badge, ThemeColor } from '@ahrowe/ui';
14
14
  import type { TabHeaderTab } from '@ahrowe/ui';
15
- import { faUser, faBell, faGear } from '@fortawesome/free-solid-svg-icons';
15
+ import { faUser, faBell, faGear, faInbox, faAt } from '@fortawesome/free-solid-svg-icons';
16
16
 
17
17
  const TABS: TabHeaderTab[] = [
18
18
  { id: 'profile', icon: faUser, title: 'Profile', subtitle: 'Your details' },
@@ -32,6 +32,16 @@ function Example() {
32
32
  onChange={setTab}
33
33
  />
34
34
 
35
+ // A count next to the title, still shown when a narrow screen collapses the tab
36
+ <TabHeader
37
+ tabs={[
38
+ { id: 'inbox', icon: faInbox, title: 'Inbox', endContent: <Badge count={12} /> },
39
+ { id: 'mentions', icon: faAt, title: 'Mentions', endContent: <Badge count={3} color={ThemeColor.Error} /> },
40
+ ]}
41
+ currentTab={tab}
42
+ onChange={setTab}
43
+ />
44
+
35
45
  // Disabled tab — dimmed and not selectable
36
46
  <TabHeader
37
47
  tabs={[
@@ -57,8 +67,9 @@ function Example() {
57
67
  |-------|------|-------------|
58
68
  | `id` | `string` | Unique identifier, matched against `currentTab` |
59
69
  | `icon` | `IconDefinition \| ReactElement` | FontAwesome icon or any React element (always visible, incl. mobile) |
60
- | `title` | `string` | Optional title next to the icon |
61
- | `subtitle` | `string` | Optional subtitle beneath the title |
70
+ | `title` | `ReactNode` | Optional title next to the icon |
71
+ | `subtitle` | `ReactNode` | Optional subtitle beneath the title |
72
+ | `endContent` | `ReactNode` | After the labels. Stays visible when a narrow screen collapses the tab, so keep it small: a `Badge`, a dot |
62
73
  | `disabled` | `boolean` | Dim the tab and prevent selection |
63
74
 
64
- **Slots:** `root` `tab` `icon` `labels` `title` `subtitle`
75
+ **Slots:** `root` `tab` `icon` `labels` `title` `subtitle` `endContent`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahrowe/ui",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "A React UI component library with theming, CSS Modules, and TypeScript support",
5
5
  "keywords": [
6
6
  "agent",