@vvedantb/ui 0.3.1 → 0.3.2

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
@@ -59,8 +59,10 @@ import { Composer, ComposerIconButton } from "@vvedantb/ui"; // or "@vvedantb/ui
59
59
  | Prop | Default | Notes |
60
60
  | --- | --- | --- |
61
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. |
62
+ | `onSubmit(text)` | none | Called with the trimmed draft on Enter or the send button. By default it is not called for an empty draft, or when `disabled` or (with `stopMode="replace"`) `streaming`. A `canSend` override that allows an empty draft calls it with `""`. If it returns a rejected promise, an uncontrolled composer restores the draft. |
63
+ | `streaming`, `onStop` | `false` | The stop button calls `onStop`. With `stopMode="replace"` it replaces send and Enter does not submit. Typing stays on. |
64
+ | `stopMode` | `"replace"` | `"alongside"` shows stop and send together while streaming, in the order `trailingActions`, stop, send. Send stays usable to queue the next message: Enter and the send button submit while streaming when `canSend` allows. Not streaming, only send shows. Predictions stay off while streaming. |
65
+ | `canSend` | trimmed draft is not empty | `boolean` or `(draft: string) => boolean`. Overrides whether send is enabled. The function gets the live draft (not trimmed). `disabled` and, in replace mode, `streaming` still block send. With attachments, use `canSend={(draft) => draft.trim() !== "" \|\| hasAttachments}`. `onSubmit` then gets `""` for an empty draft. |
64
66
  | `disabled` | `false` | Disables the textarea and send. |
65
67
  | `placeholder` | `"Message"` | Also the textarea `aria-label`. |
66
68
  | `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. |
@@ -124,7 +126,7 @@ function MentionComposer({ draft, setDraft, send }: Props) {
124
126
  }
125
127
  ```
126
128
 
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`.
129
+ `ComposerShell` props: `layout`, `disabled`, `streaming`, `canSend` (always a boolean: `Composer` resolves the function form), `stopMode` (`"replace"` or `"alongside"`, where the buttons render), `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
130
 
129
131
  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
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -15,6 +15,12 @@ import { useComposerPrediction } from "./useComposerPrediction";
15
15
  export type ComposerSlotContext = { disabled: boolean };
16
16
  export type ComposerTrailingContext = { disabled: boolean; streaming: boolean };
17
17
  export type ComposerLayout = "stacked" | "inline";
18
+ /**
19
+ * How stop behaves while `streaming`. `"replace"` (default): stop takes the
20
+ * place of send. `"alongside"`: stop and send show together, in the order
21
+ * trailing actions, stop, send.
22
+ */
23
+ export type ComposerStopMode = "replace" | "alongside";
18
24
 
19
25
  export type ComposerInputRenderContext = {
20
26
  value: string;
@@ -44,13 +50,30 @@ export type ComposerProps = {
44
50
  defaultValue?: string;
45
51
  onChange?: (value: string) => void;
46
52
 
47
- /** Called with the trimmed draft on Enter or the send button. Never called for an empty draft. */
53
+ /**
54
+ * Called with the trimmed draft on Enter or the send button. By default it
55
+ * is not called for an empty draft. A `canSend` override that allows an
56
+ * empty draft calls it with `""`.
57
+ */
48
58
  onSubmit?: (value: string) => void | Promise<void>;
49
59
  onStop?: () => void;
50
- /** Shows the stop button in place of send. Typing stays enabled. Enter does not submit. */
60
+ /**
61
+ * Shows the stop button. With `stopMode="replace"` (default) it takes the
62
+ * place of send and Enter does not submit. Typing stays enabled.
63
+ */
51
64
  streaming?: boolean;
52
- /** Disables the textarea and the send button. */
65
+ /** Disables the textarea and the send button. `canSend` cannot override it. */
53
66
  disabled?: boolean;
67
+ /**
68
+ * Overrides whether send is enabled. Default: the trimmed draft is not
69
+ * empty. `disabled` and, with `stopMode="replace"`, `streaming` still block
70
+ * send. A function receives the live draft (not trimmed), so check
71
+ * `draft.trim()` yourself. Use it to allow an empty draft when attachments
72
+ * exist: `canSend={(draft) => draft.trim() !== "" || hasAttachments}`.
73
+ */
74
+ canSend?: boolean | ((draft: string) => boolean);
75
+ /** `"replace"` (default) or `"alongside"`. See `ComposerStopMode`. */
76
+ stopMode?: ComposerStopMode;
54
77
 
55
78
  placeholder?: string;
56
79
  /** Maximum textarea height in px. The textarea scrolls after it. Default 200. */
@@ -124,8 +147,10 @@ export type ComposerShellProps = {
124
147
  layout?: ComposerLayout;
125
148
  disabled?: boolean;
126
149
  streaming?: boolean;
127
- /** Enables the send button. */
150
+ /** Enables the send button. Always a boolean: `Composer` resolves its function form. */
128
151
  canSend?: boolean;
152
+ /** `"replace"` (default) or `"alongside"`. Alongside while streaming: trailing actions, stop, send. */
153
+ stopMode?: ComposerStopMode;
129
154
  onSend?: () => void;
130
155
  onStop?: () => void;
131
156
  plusMenu?: SlotNode<ComposerSlotContext>;
@@ -147,6 +172,7 @@ function ComposerShell({
147
172
  disabled = false,
148
173
  streaming = false,
149
174
  canSend = false,
175
+ stopMode = "replace",
150
176
  onSend,
151
177
  onStop,
152
178
  plusMenu,
@@ -162,7 +188,7 @@ function ComposerShell({
162
188
  : trailingActions;
163
189
  const inline = layout === "inline";
164
190
 
165
- const action = streaming ? (
191
+ const stopButton = (
166
192
  <ComposerIconButton
167
193
  variant="default"
168
194
  aria-label="Stop generating"
@@ -171,7 +197,8 @@ function ComposerShell({
171
197
  >
172
198
  <IconPlayerStop className="size-4" aria-hidden="true" />
173
199
  </ComposerIconButton>
174
- ) : (
200
+ );
201
+ const sendButton = (
175
202
  <ComposerIconButton
176
203
  variant="default"
177
204
  aria-label="Send message"
@@ -182,6 +209,16 @@ function ComposerShell({
182
209
  <IconArrowUp className="size-4" aria-hidden="true" />
183
210
  </ComposerIconButton>
184
211
  );
212
+ const action = !streaming ? (
213
+ sendButton
214
+ ) : stopMode === "alongside" ? (
215
+ <>
216
+ {stopButton}
217
+ {sendButton}
218
+ </>
219
+ ) : (
220
+ stopButton
221
+ );
185
222
 
186
223
  return (
187
224
  <div
@@ -189,6 +226,7 @@ function ComposerShell({
189
226
  data-layout={layout}
190
227
  data-disabled={disabled || undefined}
191
228
  data-streaming={streaming || undefined}
229
+ data-stop-mode={stopMode}
192
230
  className={cn(
193
231
  "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
232
  "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",
@@ -274,6 +312,8 @@ const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function C
274
312
  onStop,
275
313
  streaming = false,
276
314
  disabled = false,
315
+ canSend: canSendProp,
316
+ stopMode = "replace",
277
317
  placeholder = "Message",
278
318
  maxHeight = DEFAULT_MAX_HEIGHT,
279
319
  autoFocus,
@@ -343,7 +383,11 @@ const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function C
343
383
  };
344
384
  React.useLayoutEffect(syncScroll, [value, ghost]);
345
385
 
346
- const canSend = !disabled && !streaming && value.trim() !== "";
386
+ const override = typeof canSendProp === "function" ? canSendProp(value) : canSendProp;
387
+ const canSend =
388
+ !disabled &&
389
+ (stopMode === "alongside" || !streaming) &&
390
+ (override ?? value.trim() !== "");
347
391
 
348
392
  const submit = () => {
349
393
  if (!canSend) return;
@@ -454,6 +498,7 @@ const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function C
454
498
  disabled={disabled}
455
499
  streaming={streaming}
456
500
  canSend={canSend}
501
+ stopMode={stopMode}
457
502
  onSend={submit}
458
503
  onStop={onStop}
459
504
  plusMenu={plusMenu}
@@ -6,6 +6,7 @@ export type {
6
6
  ComposerProps,
7
7
  ComposerShellProps,
8
8
  ComposerSlotContext,
9
+ ComposerStopMode,
9
10
  ComposerTrailingContext,
10
11
  } from "./Composer";
11
12
  export { useComposerPrediction } from "./useComposerPrediction";