@vvedantb/ui 0.2.3 → 0.3.1

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/README.md CHANGED
@@ -6,6 +6,7 @@ Shared React 19 web UI. Source-exported: no build step.
6
6
  | --- | --- |
7
7
  | `@vvedantb/ui` | Primitives (Radix-based), AI elements, `playChime` and `useReplyChime`. |
8
8
  | `@vvedantb/ui/sonner` | `SonnerToaster`, its helpers and `toast`. |
9
+ | `@vvedantb/ui/composer` | `Composer`, `ComposerShell`, `ComposerGhostText`, `ComposerIconButton`, `useComposerPrediction` and the pure prediction helpers. |
9
10
  | `@vvedantb/ui/audio` | `playChime`, `useReplyChime`, `playTone` and the shared audio floor. |
10
11
  | `@vvedantb/ui/cn` | `cn` class helper (clsx + tailwind-merge). |
11
12
  | `@vvedantb/ui/shell` | Router-free `SidebarHeader`, `PillTabs` and `SessionsHoverCard`. |
@@ -36,6 +37,97 @@ useReplyChime({ enabled: soundOn, isStreaming, threadId });
36
37
  - These are also available from `@vvedantb/ui/audio`, which does not load the rest of the root barrel.
37
38
  - `useReplyChime`: chimes once when `isStreaming` goes from `true` to `false` for the same `threadId`. It is silent on the first sight of a thread, when `threadId` changes, and when `threadId` is null or undefined. While `enabled` is `false` it still tracks the edge, so enabling mid-turn does not replay a finish.
38
39
 
40
+ ## Composer
41
+
42
+ Chat composer with an autosizing textarea, a footer with a plus-menu slot, action slots and a send or stop button. It also shows optional ghost-text predictions. It makes no network calls: the app supplies `getPrediction`.
43
+
44
+ ```tsx
45
+ import { Composer, ComposerIconButton } from "@vvedantb/ui"; // or "@vvedantb/ui/composer"
46
+
47
+ <Composer
48
+ value={draft}
49
+ onChange={setDraft}
50
+ onSubmit={(text) => send(text)}
51
+ streaming={isStreaming}
52
+ onStop={cancel}
53
+ plusMenu={<ComposerIconButton aria-label="Add">+</ComposerIconButton>}
54
+ attachments={chips}
55
+ getPrediction={(draft, { signal }) => fetchContinuation(draft, signal)}
56
+ />;
57
+ ```
58
+
59
+ | Prop | Default | Notes |
60
+ | --- | --- | --- |
61
+ | `value`, `defaultValue`, `onChange` | uncontrolled, `""` | Pass `value` to control it. When controlled, clear the value yourself after `onSubmit`. When uncontrolled, the composer clears after submit. |
62
+ | `onSubmit(text)` | none | Called with the trimmed draft on Enter or the send button. Not called for an empty draft, or when `disabled` or `streaming`. If it returns a rejected promise, an uncontrolled composer restores the draft. |
63
+ | `streaming`, `onStop` | `false` | The stop button replaces send and calls `onStop`. Typing stays on, Enter does not submit. |
64
+ | `disabled` | `false` | Disables the textarea and send. |
65
+ | `placeholder` | `"Message"` | Also the textarea `aria-label`. |
66
+ | `layout` | `"stacked"` | `"stacked"`: input above, footer bar with `plusMenu` left and `trailingActions` plus send or stop right. `"inline"`: one row with `plusMenu`, the input and the actions. Attachments stay above the row. |
67
+ | `renderInput(ctx)` | none | Supplies your own input instead of the textarea. See Building blocks. |
68
+ | `maxHeight` | `200` (px) | The textarea grows to this height, then scrolls. |
69
+ | `autoFocus`, `className`, `textareaClassName` | none | `className` is the outer shell. `textareaClassName` is also applied to the ghost text mirror, so put padding and font classes there, not on the shell. |
70
+ | `plusMenu` | none | Node, or `({ disabled }) => node`. Left of the footer. |
71
+ | `trailingActions` | none | Node, or `({ disabled, streaming }) => node`. Right of the footer, before send or stop. |
72
+ | `attachments` | none | Node rendered in a row above the textarea. The app renders the chips and their remove buttons. |
73
+ | `getPrediction(draft, { signal })` | none | Returns the continuation of `draft`. Pass `signal` to your request. Errors show no ghost text. |
74
+ | `predictionDebounceMs` | `500` (ms) | Pause after the last edit before a request. |
75
+ | `predictionMinLength` | `12` (characters) | Minimum trimmed draft length. |
76
+
77
+ Keys: Enter sends, Shift+Enter adds a line, and nothing sends during IME composition. Tab accepts the ghost text, and Esc hides it for the current draft.
78
+
79
+ Predictions skip drafts shorter than `predictionMinLength`, drafts whose last word starts with `@` or `/`, and drafts that match `/[.!?]\s*$/`. Each edit aborts the previous request, and a result for an older draft is dropped. Ghost text shows only while the caret is at the end of the draft, so Tab always appends. It does not insert at a caret in the middle of the text. A space joins the draft and the prediction when neither has whitespace at the join.
80
+
81
+ The ref goes to the textarea. `ComposerIconButton` is a round icon-only ghost button for the slots. The pure helpers `shouldSkipComposerPrediction`, `joinComposerPrediction`, `nextComposerPredictionState`, `visibleComposerPrediction` and `acceptComposerPrediction`, and the `useComposerPrediction` hook, are exported for apps with their own input.
82
+
83
+ ### Building blocks
84
+
85
+ `renderInput` swaps the textarea for your own input and keeps the shell, slots, send or stop and predictions. The context gives `value`, `onChange`, `onKeyDown`, `ghostText`, `acceptGhost`, `dismissGhost`, `inputRef`, `disabled`, `placeholder`, `streaming`, `maxHeight` and `textareaClassName`.
86
+
87
+ ```tsx
88
+ <Composer
89
+ layout="inline"
90
+ getPrediction={getPrediction}
91
+ renderInput={(ctx) => (
92
+ <MyEditor
93
+ ref={ctx.inputRef}
94
+ data-composer-input=""
95
+ value={ctx.value}
96
+ onChange={ctx.onChange}
97
+ onKeyDown={ctx.onKeyDown}
98
+ ghostText={ctx.ghostText}
99
+ />
100
+ )}
101
+ />
102
+ ```
103
+
104
+ Rules for a custom input:
105
+
106
+ - `onKeyDown` handles Enter (calls `preventDefault`, then submits, never during IME composition), Tab (accepts the ghost text) and Esc (dismisses it). Shift+Enter is not handled: your editor decides what a newline does.
107
+ - Autosize and the caret-at-end ghost gate apply to the default textarea only. With `renderInput`, predictions run whenever the composer is not disabled or streaming. You size the editor (use `maxHeight`) and decide when the caret allows ghost text, for example by ignoring `ghostText`.
108
+ - Put the `data-composer-input` attribute on the editor so the shell shows the focus ring.
109
+ - `ref` on `Composer` points at the default textarea only. Use `ctx.inputRef` for a custom input.
110
+
111
+ To build without `Composer`, assemble the parts yourself. `useComposerPrediction` takes the draft string and reads no element. `ComposerGhostText` draws the draft invisibly, then the ghost text, so give it the same padding and font classes as your editor and place it under the editor.
112
+
113
+ ```tsx
114
+ import { ComposerGhostText, ComposerShell, useComposerPrediction } from "@vvedantb/ui/composer";
115
+
116
+ function MentionComposer({ draft, setDraft, send }: Props) {
117
+ const prediction = useComposerPrediction({ value: draft, enabled: true, getPrediction });
118
+ return (
119
+ <ComposerShell layout="inline" plusMenu={plus} canSend={draft.trim() !== ""} onSend={send}>
120
+ <ComposerGhostText value={draft} ghostText={prediction.ghost} className="px-2 py-1 text-sm leading-6" />
121
+ <Editor data-composer-input="" className="relative px-2 py-1 text-sm leading-6" onChange={setDraft} />
122
+ </ComposerShell>
123
+ );
124
+ }
125
+ ```
126
+
127
+ `ComposerShell` props: `layout`, `disabled`, `streaming`, `canSend`, `onSend`, `onStop`, `plusMenu`, `trailingActions`, `attachments`, `className` and `children` (the input region). In the shell the Enter, Tab and Esc keys are yours to wire when you do not use `Composer`.
128
+
129
+ Fixture: `composer/examples/` (see its README). It has a `tokens.css` page and a `compat-shadcn-rgb.css` page, each with light and dark.
130
+
39
131
  ## Sessions hover card
40
132
 
41
133
  ```tsx
@@ -50,7 +142,7 @@ import { SessionsHoverCard } from "@vvedantb/ui/shell";
50
142
  </SessionsHoverCard>
51
143
  ```
52
144
 
53
- `children` is the trigger. It is rendered with `asChild` by default, so it keeps its own handlers (such as a context menu). Extra props and the ref go to it. Use `asChild={false}` for a default trigger element. The content portals to `document.body` by default, so `overflow:hidden` ancestors do not clip it. Pass `portal={false}` to opt out. `surface` is `"overlay"` (default), `"surface"` (`rounded-surface`) or `"glass"` (`glass-panel-strong`, `rounded-surface`). `className` merges over the surface without `!`. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. Fixture: `shell/examples/sessions-hover-card/`.
145
+ `children` is the trigger. It is rendered with `asChild` by default, so it keeps its own handlers (such as a context menu). Extra props and the ref go to it. Use `asChild={false}` for a default trigger element. The content portals to `document.body` by default, so `overflow:hidden` ancestors do not clip it. Pass `portal={false}` to opt out. `surface` is `"overlay"` (default), `"surface"` (`rounded-surface`) or `"glass"` (`glass-panel-strong`, `rounded-surface`). `className` merges over the surface without `!`. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. The card opens on pointer hover and on keyboard focus (`:focus-visible`) only. Focus that follows a click or a context menu close does not open it. If pointer-driven focus left it stuck open, pointer leave force-closes it; otherwise Radix handles close (including moving onto the content). Fixture: `shell/examples/sessions-hover-card/`.
54
146
 
55
147
  ## Toaster
56
148
 
@@ -84,6 +176,11 @@ Opt-in props (all default to the current look):
84
176
  | `closeButtonStyle` | `"default"`, `"hoverChip"` | Text-style close button or a round chip at the top-right, shown on hover and always on small screens. It turns on `closeButton` unless you set it. |
85
177
  | `sonnerVars` | `false` (default), `true` | Sets `--normal-bg`, `--normal-border`, `--normal-text`, `--normal-bg-hover` and `--normal-border-hover` from `--color-card`, `--color-border`, `--color-foreground` and `--color-muted`, and `font-family: inherit`. |
86
178
  | `descriptionMuted` | `false` (default), `true` | Description uses `text-muted-foreground!`, which beats sonner's dark-theme rule. |
179
+ | `titleLeading` | `"snug"` (default), `"5"` | Title `leading-snug` (19.25px at `text-sm`) or `leading-5` (20px). |
180
+ | `loadingIcon` | node (default none) | Custom loading icon, merged into `icons`. A caller `icons.loading` wins. `icons={{ loading }}` works with both `toastIcons` values. |
181
+ | `hideStackedContent` | `false` (default), `true` | Adds `[&[data-expanded=false][data-front=false]>*]:opacity-0` to the toast, so content behind the front toast is hidden while the stack is collapsed. `sonner.css` applies the same rule when imported. |
182
+
183
+ With `closeButtonStyle="hoverChip"`, hover changes the text colour, and sonner may still change the background. The border stays `border-border` (pinned with `hover:border-border!` over sonner's dark hover border), so the ring does not brighten in dark mode.
87
184
 
88
185
  Helpers: `createToasterClassNames`, `mergeToastClassNames`, `mergeToastOptions`, `mergeToasterStyle`, `filledToastIcons`. Fixture: `tokens/examples/toaster/`.
89
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: primitives, shell pieces, app helpers, design tokens, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
@@ -29,6 +29,10 @@
29
29
  "types": "./src/audio/index.ts",
30
30
  "default": "./src/audio/index.ts"
31
31
  },
32
+ "./composer": {
33
+ "types": "./src/composer/index.ts",
34
+ "default": "./src/composer/index.ts"
35
+ },
32
36
  "./shell": {
33
37
  "types": "./shell/src/index.ts",
34
38
  "default": "./shell/src/index.ts"
@@ -70,6 +70,15 @@ export type SessionsHoverCardProps = Omit<
70
70
  className?: string;
71
71
  };
72
72
 
73
+ /** `:focus-visible` is true for keyboard focus only. Treat unsupported engines as keyboard. */
74
+ function isFocusVisible(el: Element): boolean {
75
+ try {
76
+ return el.matches(":focus-visible");
77
+ } catch {
78
+ return true;
79
+ }
80
+ }
81
+
73
82
  type LoadState = { status: "loading" } | { status: "ready"; node: ReactNode };
74
83
 
75
84
  /** Mounts only while the card is open, so `loadContent` never runs while closed. */
@@ -140,6 +149,8 @@ export const SessionsHoverCard = forwardRef<
140
149
  align = "start",
141
150
  sideOffset = 8,
142
151
  className,
152
+ onFocus,
153
+ onPointerLeave,
143
154
  ...triggerProps
144
155
  }, ref) {
145
156
  const [openState, setOpenState] = useState(defaultOpen);
@@ -150,6 +161,24 @@ export const SessionsHoverCard = forwardRef<
150
161
  onOpenChange?.(next);
151
162
  };
152
163
 
164
+ // Open on keyboard focus only. Focus that returns after a context menu or a
165
+ // click is pointer-driven, so it must not open the card.
166
+ const handleFocus = (event: React.FocusEvent<HTMLElement>) => {
167
+ onFocus?.(event as never);
168
+ if (event.defaultPrevented) return;
169
+ if (!isFocusVisible(event.currentTarget)) event.preventDefault();
170
+ };
171
+
172
+ // Radix handles hover close, including closeDelay and moving onto the
173
+ // content. Force-close only when the card is stuck open by pointer-driven
174
+ // focus on the trigger (focused, but not `:focus-visible`).
175
+ const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {
176
+ onPointerLeave?.(event as never);
177
+ if (event.pointerType === "touch" || !open) return;
178
+ const el = event.currentTarget;
179
+ if (el.ownerDocument.activeElement === el && !isFocusVisible(el)) handleOpenChange(false);
180
+ };
181
+
153
182
  const bodyNode = loadContent ? (
154
183
  <LoadedBody loadContent={loadContent} fallback={loadingFallback} />
155
184
  ) : typeof body === "function" ? (
@@ -190,7 +219,13 @@ export const SessionsHoverCard = forwardRef<
190
219
  openDelay={openDelay}
191
220
  closeDelay={closeDelay}
192
221
  >
193
- <HoverCardTrigger ref={ref} asChild={asChild} {...triggerProps}>
222
+ <HoverCardTrigger
223
+ ref={ref}
224
+ asChild={asChild}
225
+ {...triggerProps}
226
+ onFocus={handleFocus}
227
+ onPointerLeave={handlePointerLeave}
228
+ >
194
229
  {children}
195
230
  </HoverCardTrigger>
196
231
  {portal ? <HoverCardPrimitive.Portal>{content}</HoverCardPrimitive.Portal> : content}
@@ -0,0 +1,469 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconArrowUp, IconPlayerStop } from "@tabler/icons-react";
5
+
6
+ import { cn } from "../utils/cn";
7
+ import { Button } from "../ui/button";
8
+ import {
9
+ COMPOSER_PREDICTION_DEBOUNCE_MS,
10
+ COMPOSER_PREDICTION_MIN_LENGTH,
11
+ acceptComposerPrediction,
12
+ } from "./prediction";
13
+ import { useComposerPrediction } from "./useComposerPrediction";
14
+
15
+ export type ComposerSlotContext = { disabled: boolean };
16
+ export type ComposerTrailingContext = { disabled: boolean; streaming: boolean };
17
+ export type ComposerLayout = "stacked" | "inline";
18
+
19
+ export type ComposerInputRenderContext = {
20
+ value: string;
21
+ onChange: (value: string) => void;
22
+ /**
23
+ * Shared key handler. Enter submits (preventDefault, not while composing),
24
+ * Tab accepts the ghost text, Esc dismisses it. It ignores IME composition.
25
+ * Shift+Enter is not handled: the caller decides what a newline does.
26
+ */
27
+ onKeyDown: (event: React.KeyboardEvent) => void;
28
+ /** Visible ghost text including the joining space, or `""`. */
29
+ ghostText: string;
30
+ acceptGhost: () => void;
31
+ dismissGhost: () => void;
32
+ /** Attach to the input element (textarea or contenteditable root). */
33
+ inputRef: React.RefObject<HTMLElement | null>;
34
+ disabled: boolean;
35
+ placeholder: string;
36
+ streaming: boolean;
37
+ maxHeight: number;
38
+ textareaClassName?: string;
39
+ };
40
+
41
+ export type ComposerProps = {
42
+ /** Controlled value. Omit it for an uncontrolled composer. */
43
+ value?: string;
44
+ defaultValue?: string;
45
+ onChange?: (value: string) => void;
46
+
47
+ /** Called with the trimmed draft on Enter or the send button. Never called for an empty draft. */
48
+ onSubmit?: (value: string) => void | Promise<void>;
49
+ onStop?: () => void;
50
+ /** Shows the stop button in place of send. Typing stays enabled. Enter does not submit. */
51
+ streaming?: boolean;
52
+ /** Disables the textarea and the send button. */
53
+ disabled?: boolean;
54
+
55
+ placeholder?: string;
56
+ /** Maximum textarea height in px. The textarea scrolls after it. Default 200. */
57
+ maxHeight?: number;
58
+ autoFocus?: boolean;
59
+ /** Classes for the outer shell. */
60
+ className?: string;
61
+ /** `"stacked"` (default): input above, footer bar below. `"inline"`: one row with plus, input and actions. */
62
+ layout?: ComposerLayout;
63
+ /**
64
+ * Supplies your own input (for example a contenteditable editor) in place of
65
+ * the textarea. The shell, slots, send/stop and predictions stay. Autosize
66
+ * and the caret-at-end gate apply to the default textarea only: the caller
67
+ * sizes the editor and decides when ghost text is valid.
68
+ */
69
+ renderInput?: (ctx: ComposerInputRenderContext) => React.ReactNode;
70
+ /** Classes for the textarea. Also applied to the ghost text mirror, so keep padding and font here. */
71
+ textareaClassName?: string;
72
+
73
+ /** Left of the footer. */
74
+ plusMenu?: React.ReactNode | ((ctx: ComposerSlotContext) => React.ReactNode);
75
+ /** Right of the footer, before send or stop. */
76
+ trailingActions?:
77
+ | React.ReactNode
78
+ | ((ctx: ComposerTrailingContext) => React.ReactNode);
79
+ /** Row above the textarea. The caller renders the chips and their remove buttons. */
80
+ attachments?: React.ReactNode;
81
+
82
+ /**
83
+ * Returns the continuation of a draft, shown as ghost text. The package makes
84
+ * no requests: the caller owns the network call and should pass `signal` to it.
85
+ */
86
+ getPrediction?: (draft: string, opts: { signal: AbortSignal }) => Promise<string>;
87
+ /** Default 500 ms. */
88
+ predictionDebounceMs?: number;
89
+ /** Default 12 characters (trimmed). */
90
+ predictionMinLength?: number;
91
+ };
92
+
93
+ const DEFAULT_MAX_HEIGHT = 200;
94
+
95
+ // The textarea and its ghost text mirror must share these metrics.
96
+ const TEXT_METRICS_BASE = "block w-full text-sm leading-6 break-words whitespace-pre-wrap";
97
+ const textMetrics = (layout: ComposerLayout) =>
98
+ cn(TEXT_METRICS_BASE, layout === "inline" ? "px-2 py-1" : "px-4 pt-3.5 pb-1");
99
+
100
+ function isCaretAtEnd(el: HTMLTextAreaElement): boolean {
101
+ return el.selectionStart === el.selectionEnd && el.selectionEnd === el.value.length;
102
+ }
103
+
104
+ /** Icon-only ghost button for the footer: `plusMenu` triggers, `trailingActions`. */
105
+ const ComposerIconButton = React.forwardRef<
106
+ HTMLButtonElement,
107
+ React.ComponentProps<typeof Button>
108
+ >(({ className, variant = "ghost", size = "icon-sm", type = "button", ...props }, ref) => (
109
+ <Button
110
+ ref={ref}
111
+ type={type}
112
+ variant={variant}
113
+ size={size}
114
+ className={cn("rounded-full", className)}
115
+ {...props}
116
+ />
117
+ ));
118
+ ComposerIconButton.displayName = "ComposerIconButton";
119
+
120
+
121
+ type SlotNode<C> = React.ReactNode | ((ctx: C) => React.ReactNode);
122
+
123
+ export type ComposerShellProps = {
124
+ layout?: ComposerLayout;
125
+ disabled?: boolean;
126
+ streaming?: boolean;
127
+ /** Enables the send button. */
128
+ canSend?: boolean;
129
+ onSend?: () => void;
130
+ onStop?: () => void;
131
+ plusMenu?: SlotNode<ComposerSlotContext>;
132
+ trailingActions?: SlotNode<ComposerTrailingContext>;
133
+ attachments?: React.ReactNode;
134
+ className?: string;
135
+ /** The input region: a textarea, a contenteditable, plus any ghost overlay. */
136
+ children?: React.ReactNode;
137
+ };
138
+
139
+ /**
140
+ * Outer chrome of the composer: border, field tokens, focus ring, attachments,
141
+ * plus-menu and trailing slots, and send or stop. Put your input in `children`.
142
+ * The focus ring follows any descendant with the `data-composer-input`
143
+ * attribute that matches `:focus-visible`.
144
+ */
145
+ function ComposerShell({
146
+ layout = "stacked",
147
+ disabled = false,
148
+ streaming = false,
149
+ canSend = false,
150
+ onSend,
151
+ onStop,
152
+ plusMenu,
153
+ trailingActions,
154
+ attachments,
155
+ className,
156
+ children,
157
+ }: ComposerShellProps) {
158
+ const plus = typeof plusMenu === "function" ? plusMenu({ disabled }) : plusMenu;
159
+ const trailing =
160
+ typeof trailingActions === "function"
161
+ ? trailingActions({ disabled, streaming })
162
+ : trailingActions;
163
+ const inline = layout === "inline";
164
+
165
+ const action = streaming ? (
166
+ <ComposerIconButton
167
+ variant="default"
168
+ aria-label="Stop generating"
169
+ data-slot="composer-stop"
170
+ onClick={() => onStop?.()}
171
+ >
172
+ <IconPlayerStop className="size-4" aria-hidden="true" />
173
+ </ComposerIconButton>
174
+ ) : (
175
+ <ComposerIconButton
176
+ variant="default"
177
+ aria-label="Send message"
178
+ data-slot="composer-send"
179
+ disabled={!canSend}
180
+ onClick={() => onSend?.()}
181
+ >
182
+ <IconArrowUp className="size-4" aria-hidden="true" />
183
+ </ComposerIconButton>
184
+ );
185
+
186
+ return (
187
+ <div
188
+ data-slot="composer"
189
+ data-layout={layout}
190
+ data-disabled={disabled || undefined}
191
+ data-streaming={streaming || undefined}
192
+ className={cn(
193
+ "group/composer relative flex w-full min-w-0 flex-col rounded-field border border-border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth",
194
+ "has-[[data-composer-input]:focus-visible]:border-focus-border has-[[data-composer-input]:focus-visible]:ring-2 has-[[data-composer-input]:focus-visible]:ring-focus-ring",
195
+ disabled && "opacity-60",
196
+ className,
197
+ )}
198
+ >
199
+ {attachments ? (
200
+ <div data-slot="composer-attachments" className="flex flex-wrap items-center gap-2 px-3 pt-3">
201
+ {attachments}
202
+ </div>
203
+ ) : null}
204
+
205
+ {inline ? (
206
+ <div data-slot="composer-row" className="flex min-w-0 items-end gap-1 p-1.5">
207
+ {plus ? <div className="flex shrink-0 items-center gap-1">{plus}</div> : null}
208
+ <div data-slot="composer-input-region" className="relative min-w-0 flex-1">
209
+ {children}
210
+ </div>
211
+ <div className="flex shrink-0 items-center gap-1">
212
+ {trailing}
213
+ {action}
214
+ </div>
215
+ </div>
216
+ ) : (
217
+ <>
218
+ <div data-slot="composer-input-region" className="relative min-w-0">
219
+ {children}
220
+ </div>
221
+ <div data-slot="composer-footer" className="flex items-center gap-1 px-2 pt-1 pb-2">
222
+ {plus ? <div className="flex items-center gap-1">{plus}</div> : null}
223
+ <div className="ml-auto flex items-center gap-1">
224
+ {trailing}
225
+ {action}
226
+ </div>
227
+ </div>
228
+ </>
229
+ )}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ export type ComposerGhostTextProps = {
235
+ value: string;
236
+ ghostText: string;
237
+ className?: string;
238
+ };
239
+
240
+ /**
241
+ * Input-agnostic ghost overlay. It renders the draft invisibly, then the ghost
242
+ * text, so the ghost lands after the last character. Position it absolutely
243
+ * under (or over) your input and give it the same padding and font classes.
244
+ */
245
+ const ComposerGhostText = React.forwardRef<HTMLDivElement, ComposerGhostTextProps>(
246
+ function ComposerGhostText({ value, ghostText, className }, ref) {
247
+ return (
248
+ <div
249
+ ref={ref}
250
+ aria-hidden="true"
251
+ data-slot="composer-ghost"
252
+ className={cn(
253
+ "pointer-events-none absolute inset-0 overflow-y-auto [scrollbar-color:transparent_transparent] [&::-webkit-scrollbar]:bg-transparent",
254
+ className,
255
+ )}
256
+ >
257
+ <span className="invisible">{value}</span>
258
+ {ghostText ? <span className="text-muted-foreground/50">{ghostText}</span> : null}
259
+ </div>
260
+ );
261
+ },
262
+ );
263
+
264
+ /**
265
+ * Chat composer: autosizing textarea, plus-menu and action slots, send/stop
266
+ * button and optional ghost-text predictions. The ref points at the textarea.
267
+ */
268
+ const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function Composer(
269
+ {
270
+ value: valueProp,
271
+ defaultValue = "",
272
+ onChange,
273
+ onSubmit,
274
+ onStop,
275
+ streaming = false,
276
+ disabled = false,
277
+ placeholder = "Message",
278
+ maxHeight = DEFAULT_MAX_HEIGHT,
279
+ autoFocus,
280
+ className,
281
+ textareaClassName,
282
+ layout = "stacked",
283
+ renderInput,
284
+ plusMenu,
285
+ trailingActions,
286
+ attachments,
287
+ getPrediction,
288
+ predictionDebounceMs = COMPOSER_PREDICTION_DEBOUNCE_MS,
289
+ predictionMinLength = COMPOSER_PREDICTION_MIN_LENGTH,
290
+ },
291
+ forwardedRef,
292
+ ) {
293
+ const isControlled = valueProp !== undefined;
294
+ const [inner, setInner] = React.useState(defaultValue);
295
+ const value = isControlled ? valueProp : inner;
296
+
297
+ const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
298
+ const mirrorRef = React.useRef<HTMLDivElement | null>(null);
299
+ const customInputRef = React.useRef<HTMLElement | null>(null);
300
+ const custom = renderInput !== undefined;
301
+ const setRefs = React.useCallback(
302
+ (node: HTMLTextAreaElement | null) => {
303
+ textareaRef.current = node;
304
+ if (typeof forwardedRef === "function") forwardedRef(node);
305
+ else if (forwardedRef) forwardedRef.current = node;
306
+ },
307
+ [forwardedRef],
308
+ );
309
+
310
+ const setValue = React.useCallback(
311
+ (next: string) => {
312
+ if (!isControlled) setInner(next);
313
+ onChange?.(next);
314
+ },
315
+ [isControlled, onChange],
316
+ );
317
+
318
+ // Ghost text shows only while the caret sits at the end of the draft.
319
+ const [caretAtEnd, setCaretAtEnd] = React.useState(true);
320
+ const prediction = useComposerPrediction({
321
+ value,
322
+ enabled: (custom || caretAtEnd) && !disabled && !streaming,
323
+ getPrediction,
324
+ debounceMs: predictionDebounceMs,
325
+ minLength: predictionMinLength,
326
+ });
327
+ const ghost = prediction.ghost;
328
+
329
+ // Autosize: grow with the content up to `maxHeight`, then scroll.
330
+ React.useLayoutEffect(() => {
331
+ const el = textareaRef.current;
332
+ if (custom || !el) return;
333
+ el.style.height = "auto";
334
+ const full = el.scrollHeight;
335
+ el.style.height = `${Math.min(full, maxHeight)}px`;
336
+ el.style.overflowY = full > maxHeight ? "auto" : "hidden";
337
+ }, [value, maxHeight, custom]);
338
+
339
+ const syncScroll = () => {
340
+ if (mirrorRef.current && textareaRef.current) {
341
+ mirrorRef.current.scrollTop = textareaRef.current.scrollTop;
342
+ }
343
+ };
344
+ React.useLayoutEffect(syncScroll, [value, ghost]);
345
+
346
+ const canSend = !disabled && !streaming && value.trim() !== "";
347
+
348
+ const submit = () => {
349
+ if (!canSend) return;
350
+ const draft = value.trim();
351
+ const result = onSubmit?.(draft);
352
+ if (!isControlled) setInner("");
353
+ if (!isControlled) onChange?.("");
354
+ prediction.clear();
355
+ if (result && typeof result.then === "function") {
356
+ // A failed async submit gives the draft back, unless the user already typed again.
357
+ result.then(undefined, () => {
358
+ if (!isControlled) setInner((current) => (current === "" ? draft : current));
359
+ });
360
+ }
361
+ };
362
+
363
+ const acceptGhost = () => {
364
+ if (!ghost) return;
365
+ prediction.clear();
366
+ setValue(acceptComposerPrediction(value, ghost));
367
+ };
368
+
369
+ const onKeyDown = (event: React.KeyboardEvent) => {
370
+ const el = event.currentTarget;
371
+ const native = event.nativeEvent;
372
+ if (native.isComposing || event.keyCode === 229) return;
373
+
374
+ if (event.key === "Tab" && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
375
+ // The caret gate applies to the default textarea only. A custom input owns it.
376
+ const atEnd = custom || !(el instanceof HTMLTextAreaElement) || isCaretAtEnd(el);
377
+ if (ghost && atEnd) {
378
+ event.preventDefault();
379
+ acceptGhost();
380
+ }
381
+ return;
382
+ }
383
+ if (event.key === "Escape") {
384
+ if (ghost) {
385
+ event.preventDefault();
386
+ prediction.dismiss();
387
+ }
388
+ return;
389
+ }
390
+ if (event.key === "Enter" && !event.shiftKey) {
391
+ event.preventDefault();
392
+ submit();
393
+ }
394
+ };
395
+
396
+ const updateCaret = (el: HTMLTextAreaElement) => setCaretAtEnd(isCaretAtEnd(el));
397
+
398
+ const metrics = textMetrics(layout);
399
+
400
+ const input = renderInput ? (
401
+ renderInput({
402
+ value,
403
+ onChange: setValue,
404
+ onKeyDown,
405
+ ghostText: ghost,
406
+ acceptGhost,
407
+ dismissGhost: prediction.dismiss,
408
+ inputRef: customInputRef,
409
+ disabled,
410
+ placeholder,
411
+ streaming,
412
+ maxHeight,
413
+ textareaClassName,
414
+ })
415
+ ) : (
416
+ <>
417
+ <ComposerGhostText
418
+ ref={mirrorRef}
419
+ value={value}
420
+ ghostText={ghost}
421
+ className={cn(metrics, textareaClassName)}
422
+ />
423
+ <textarea
424
+ ref={setRefs}
425
+ data-slot="composer-textarea"
426
+ data-composer-input=""
427
+ rows={1}
428
+ value={value}
429
+ placeholder={placeholder}
430
+ aria-label={placeholder}
431
+ disabled={disabled}
432
+ autoFocus={autoFocus}
433
+ onChange={(event) => {
434
+ setValue(event.target.value);
435
+ updateCaret(event.target);
436
+ }}
437
+ onKeyDown={onKeyDown}
438
+ onSelect={(event) => updateCaret(event.currentTarget)}
439
+ onKeyUp={(event) => updateCaret(event.currentTarget)}
440
+ onClick={(event) => updateCaret(event.currentTarget)}
441
+ onScroll={syncScroll}
442
+ className={cn(
443
+ metrics,
444
+ "relative resize-none overflow-y-hidden border-0 bg-transparent text-foreground outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed",
445
+ textareaClassName,
446
+ )}
447
+ />
448
+ </>
449
+ );
450
+
451
+ return (
452
+ <ComposerShell
453
+ layout={layout}
454
+ disabled={disabled}
455
+ streaming={streaming}
456
+ canSend={canSend}
457
+ onSend={submit}
458
+ onStop={onStop}
459
+ plusMenu={plusMenu}
460
+ trailingActions={trailingActions}
461
+ attachments={attachments}
462
+ className={className}
463
+ >
464
+ {input}
465
+ </ComposerShell>
466
+ );
467
+ });
468
+
469
+ export { Composer, ComposerGhostText, ComposerIconButton, ComposerShell };
@@ -0,0 +1,23 @@
1
+ export { Composer, ComposerGhostText, ComposerIconButton, ComposerShell } from "./Composer";
2
+ export type {
3
+ ComposerGhostTextProps,
4
+ ComposerInputRenderContext,
5
+ ComposerLayout,
6
+ ComposerProps,
7
+ ComposerShellProps,
8
+ ComposerSlotContext,
9
+ ComposerTrailingContext,
10
+ } from "./Composer";
11
+ export { useComposerPrediction } from "./useComposerPrediction";
12
+ export type { ComposerPrediction, UseComposerPredictionOptions } from "./useComposerPrediction";
13
+ export {
14
+ COMPOSER_PREDICTION_DEBOUNCE_MS,
15
+ COMPOSER_PREDICTION_MIN_LENGTH,
16
+ acceptComposerPrediction,
17
+ initialComposerPredictionState,
18
+ joinComposerPrediction,
19
+ nextComposerPredictionState,
20
+ shouldSkipComposerPrediction,
21
+ visibleComposerPrediction,
22
+ } from "./prediction";
23
+ export type { ComposerPredictionAction, ComposerPredictionState } from "./prediction";
@@ -0,0 +1,94 @@
1
+ /** Default trailing-edge debounce, in ms: how long the draft must sit before a prediction is requested. */
2
+ export const COMPOSER_PREDICTION_DEBOUNCE_MS = 500;
3
+
4
+ /** Default minimum draft length (trimmed characters) before a prediction is requested. */
5
+ export const COMPOSER_PREDICTION_MIN_LENGTH = 12;
6
+
7
+ /**
8
+ * True when no prediction should be requested for this draft.
9
+ *
10
+ * Skips drafts shorter than `minLength` (trimmed), drafts whose last word starts
11
+ * with `@` or `/` (an in-progress mention or command), and drafts that end a
12
+ * sentence (`.`, `!` or `?`, then optional whitespace).
13
+ */
14
+ export function shouldSkipComposerPrediction(
15
+ value: string,
16
+ minLength: number = COMPOSER_PREDICTION_MIN_LENGTH,
17
+ ): boolean {
18
+ if (value.trim().length < minLength) return true;
19
+ const lastWord = value.split(/\s/).pop() ?? "";
20
+ if (lastWord.startsWith("@") || lastWord.startsWith("/")) return true;
21
+ if (/[.!?]\s*$/.test(value)) return true;
22
+ return false;
23
+ }
24
+
25
+ /**
26
+ * The ghost text for a draft: the bare continuation from the caller, with a
27
+ * joining space added when neither side has whitespace at the join.
28
+ */
29
+ export function joinComposerPrediction(draft: string, prediction: string): string {
30
+ if (prediction === "") return "";
31
+ return /\s$/.test(draft) || /^\s/.test(prediction) ? prediction : ` ${prediction}`;
32
+ }
33
+
34
+ /** What `getPrediction` last returned, and the draft it was requested for. */
35
+ export type ComposerPredictionState = {
36
+ /** Draft the stored `text` answers, or `null` when there is none. */
37
+ forValue: string | null;
38
+ /** Raw text from `getPrediction` (no joining space). */
39
+ text: string;
40
+ /** Draft for which the user pressed Esc. Suppresses requests until the draft changes. */
41
+ dismissedFor: string | null;
42
+ };
43
+
44
+ export type ComposerPredictionAction =
45
+ /** A request finished. `current` is the draft at the time it finished. */
46
+ | { type: "result"; forValue: string; text: string; current: string }
47
+ /** Esc: hide the prediction for this draft. */
48
+ | { type: "dismiss"; value: string }
49
+ /** Tab accepted the prediction, or the caller reset it. */
50
+ | { type: "clear" };
51
+
52
+ export const initialComposerPredictionState: ComposerPredictionState = {
53
+ forValue: null,
54
+ text: "",
55
+ dismissedFor: null,
56
+ };
57
+
58
+ /**
59
+ * Pure reducer for prediction state. A result is dropped when the draft moved
60
+ * on while the request ran, or when it is empty.
61
+ */
62
+ export function nextComposerPredictionState(
63
+ state: ComposerPredictionState,
64
+ action: ComposerPredictionAction,
65
+ ): ComposerPredictionState {
66
+ switch (action.type) {
67
+ case "result":
68
+ if (action.forValue !== action.current || action.text === "") return state;
69
+ return { ...state, forValue: action.forValue, text: action.text };
70
+ case "dismiss":
71
+ return { forValue: null, text: "", dismissedFor: action.value };
72
+ case "clear":
73
+ return { ...state, forValue: null, text: "" };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Ghost text to show for `value`, or `""`. It is empty when the state answers a
79
+ * different draft, the draft is dismissed or the draft is skipped.
80
+ */
81
+ export function visibleComposerPrediction(
82
+ state: ComposerPredictionState,
83
+ value: string,
84
+ minLength: number = COMPOSER_PREDICTION_MIN_LENGTH,
85
+ ): string {
86
+ if (state.forValue !== value || state.dismissedFor === value) return "";
87
+ if (shouldSkipComposerPrediction(value, minLength)) return "";
88
+ return joinComposerPrediction(value, state.text);
89
+ }
90
+
91
+ /** The draft after Tab: the draft with the ghost text appended. */
92
+ export function acceptComposerPrediction(value: string, ghost: string): string {
93
+ return value + ghost;
94
+ }
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+
3
+ import {
4
+ COMPOSER_PREDICTION_DEBOUNCE_MS,
5
+ COMPOSER_PREDICTION_MIN_LENGTH,
6
+ initialComposerPredictionState,
7
+ nextComposerPredictionState,
8
+ shouldSkipComposerPrediction,
9
+ visibleComposerPrediction,
10
+ } from "./prediction";
11
+
12
+ export type UseComposerPredictionOptions = {
13
+ value: string;
14
+ /** Off when there is no `getPrediction`, the composer is busy, or the caret is not at the end. */
15
+ enabled: boolean;
16
+ getPrediction?: (draft: string, opts: { signal: AbortSignal }) => Promise<string>;
17
+ debounceMs?: number;
18
+ minLength?: number;
19
+ };
20
+
21
+ export type ComposerPrediction = {
22
+ /** Ghost text to render after the draft, including any joining space. `""` when none. */
23
+ ghost: string;
24
+ dismiss: () => void;
25
+ clear: () => void;
26
+ };
27
+
28
+ /**
29
+ * Debounced ghost-text prediction for a draft. It does no I/O itself: the caller
30
+ * supplies `getPrediction`. Each draft change aborts the previous request. A
31
+ * result that arrives after its signal aborted, or after the draft changed, is
32
+ * dropped. Errors from `getPrediction` are swallowed: a failed prediction shows
33
+ * nothing.
34
+ */
35
+ export function useComposerPrediction({
36
+ value,
37
+ enabled,
38
+ getPrediction,
39
+ debounceMs = COMPOSER_PREDICTION_DEBOUNCE_MS,
40
+ minLength = COMPOSER_PREDICTION_MIN_LENGTH,
41
+ }: UseComposerPredictionOptions): ComposerPrediction {
42
+ const [state, dispatch] = React.useReducer(
43
+ nextComposerPredictionState,
44
+ initialComposerPredictionState,
45
+ );
46
+
47
+ const latestValue = React.useRef(value);
48
+ latestValue.current = value;
49
+ const getPredictionRef = React.useRef(getPrediction);
50
+ getPredictionRef.current = getPrediction;
51
+ const active = enabled && getPrediction !== undefined;
52
+
53
+ const skip = shouldSkipComposerPrediction(value, minLength);
54
+ const settled = state.forValue === value || state.dismissedFor === value;
55
+
56
+ React.useEffect(() => {
57
+ if (!active || skip || settled) return;
58
+ const controller = new AbortController();
59
+ const timer = setTimeout(() => {
60
+ const request = getPredictionRef.current;
61
+ if (!request) return;
62
+ request(value, { signal: controller.signal }).then(
63
+ (text) => {
64
+ if (controller.signal.aborted) return;
65
+ dispatch({ type: "result", forValue: value, text, current: latestValue.current });
66
+ },
67
+ () => {},
68
+ );
69
+ }, debounceMs);
70
+ return () => {
71
+ clearTimeout(timer);
72
+ controller.abort();
73
+ };
74
+ }, [value, active, skip, settled, debounceMs]);
75
+
76
+ const ghost = active ? visibleComposerPrediction(state, value, minLength) : "";
77
+
78
+ return {
79
+ ghost,
80
+ dismiss: React.useCallback(
81
+ () => dispatch({ type: "dismiss", value: latestValue.current }),
82
+ [],
83
+ ),
84
+ clear: React.useCallback(() => dispatch({ type: "clear" }), []),
85
+ };
86
+ }
package/src/index.ts CHANGED
@@ -98,3 +98,5 @@ export * from "./ai-elements/speech-input";
98
98
  export * from "./ai-elements/suggestion";
99
99
 
100
100
  export * from "./audio";
101
+
102
+ export * from "./composer";
package/src/ui/sonner.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { CSSProperties } from "react";
1
+ import type { CSSProperties, ReactNode } from "react";
2
2
  import { Toaster, type ToasterProps } from "sonner";
3
3
  import { filledToastIcons } from "./sonner-icons";
4
4
  import "./sonner.css";
@@ -45,10 +45,22 @@ type SonnerToasterOptions = {
45
45
  sonnerVars?: boolean;
46
46
  /** When true, descriptions use `text-muted-foreground!` so sonner's dark rule cannot win. */
47
47
  descriptionMuted?: boolean;
48
+ /** `"snug"` (default) keeps `leading-snug`. `"5"` uses `leading-5` (20px at `text-sm`). */
49
+ titleLeading?: "snug" | "5";
50
+ /** Custom loading icon. Merged into `icons`. A caller `icons.loading` wins. */
51
+ loadingIcon?: ReactNode;
52
+ /**
53
+ * When true, adds a class that hides the content of toasts behind the front toast
54
+ * while the stack is collapsed. Default false. `sonner.css` has the same rule.
55
+ */
56
+ hideStackedContent?: boolean;
48
57
  };
49
58
 
59
+ const hideStackedContentClassName =
60
+ "[&[data-expanded=false][data-front=false]>*]:opacity-0";
61
+
50
62
  const hoverChipCloseButtonClassName =
51
- "pointer-events-none absolute -right-2 -top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-card/95 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover:pointer-events-auto group-hover:opacity-100 max-sm:pointer-events-auto max-sm:opacity-100 max-sm:hit-target";
63
+ "pointer-events-none absolute -right-2 -top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-card/95 text-muted-foreground opacity-0 transition-opacity hover:border-border! hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover:pointer-events-auto group-hover:opacity-100 max-sm:pointer-events-auto max-sm:opacity-100 max-sm:hit-target";
52
64
 
53
65
  const defaultCloseButtonClassName =
54
66
  "rounded-lg bg-default px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-default/78";
@@ -65,16 +77,23 @@ function createToasterClassNames({
65
77
  density = "default",
66
78
  closeButtonStyle = "default",
67
79
  descriptionMuted = false,
80
+ titleLeading = "snug",
81
+ hideStackedContent = false,
68
82
  }: Pick<
69
83
  SonnerToasterOptions,
70
- "toastRadius" | "density" | "closeButtonStyle" | "descriptionMuted"
84
+ | "toastRadius"
85
+ | "density"
86
+ | "closeButtonStyle"
87
+ | "descriptionMuted"
88
+ | "titleLeading"
89
+ | "hideStackedContent"
71
90
  > = {}): ToastClassNames {
72
91
  const radius = toastRadiusClasses[toastRadius];
73
92
  const compact = density === "compact";
74
93
  const descriptionSize = compact ? "text-xs" : "text-[13px]";
75
94
  return {
76
- toast: `group toast glass-panel-strong flex w-full items-center ${compact ? "gap-2" : "gap-3"} ${radius} ${compact ? "px-3 py-2 text-xs" : "px-4 py-3 text-sm"} text-foreground smooth-shadow-ring-md`,
77
- title: "font-medium leading-snug",
95
+ toast: `group toast glass-panel-strong flex w-full items-center ${compact ? "gap-2" : "gap-3"} ${radius} ${compact ? "px-3 py-2 text-xs" : "px-4 py-3 text-sm"} text-foreground smooth-shadow-ring-md${hideStackedContent ? ` ${hideStackedContentClassName}` : ""}`,
96
+ title: titleLeading === "5" ? "font-medium leading-5" : "font-medium leading-snug",
78
97
  description: descriptionMuted
79
98
  ? `${descriptionSize} leading-snug text-muted-foreground!`
80
99
  : `${descriptionSize} leading-snug text-muted-foreground`,
@@ -148,6 +167,9 @@ function SonnerToaster({
148
167
  closeButtonStyle = "default",
149
168
  sonnerVars = false,
150
169
  descriptionMuted,
170
+ titleLeading,
171
+ loadingIcon,
172
+ hideStackedContent,
151
173
  toastOptions,
152
174
  icons,
153
175
  style,
@@ -155,8 +177,14 @@ function SonnerToaster({
155
177
  closeButton,
156
178
  ...props
157
179
  }: ToasterProps & SonnerToasterOptions) {
180
+ const loadingOverride =
181
+ loadingIcon != null ? { loading: loadingIcon } : undefined;
158
182
  const mergedIcons =
159
- toastIcons === "filled24" ? { ...filledToastIcons, ...icons } : icons;
183
+ toastIcons === "filled24"
184
+ ? { ...filledToastIcons, ...loadingOverride, ...icons }
185
+ : loadingOverride
186
+ ? { ...loadingOverride, ...icons }
187
+ : icons;
160
188
  return (
161
189
  <Toaster
162
190
  expand={false}
@@ -175,6 +203,8 @@ function SonnerToaster({
175
203
  density,
176
204
  closeButtonStyle,
177
205
  descriptionMuted,
206
+ titleLeading,
207
+ hideStackedContent,
178
208
  }),
179
209
  },
180
210
  toastOptions,