@streamsai/ui 0.1.3 → 0.1.5

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.
@@ -73,8 +73,6 @@ export type SlashCommandCandidate = {
73
73
  insertText?: string | null;
74
74
  /** Menu section; the menu lists `cycles` before `commands`. Ungrouped candidates show under one title. */
75
75
  group?: 'cycles' | 'commands';
76
- /** Draws a soft sweeping highlight behind the row, to point a first-time user at it. */
77
- emphasis?: boolean;
78
76
  };
79
77
  /** Host-supplied emoji for the `:` shortcode typeahead (Slack-style `:smile:`). */
80
78
  export type EmojiCandidate = {
@@ -21,9 +21,8 @@ import { $createLinkNode, $isLinkNode, LinkNode, AutoLinkNode } from '@lexical/l
21
21
  import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code';
22
22
  import { BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, BOLD_UNDERSCORE, CODE, INLINE_CODE, ITALIC_STAR, ITALIC_UNDERSCORE, LINK, ORDERED_LIST, STRIKETHROUGH, UNORDERED_LIST, } from '@lexical/markdown';
23
23
  import { cn } from '../../lib/utils.js';
24
- import { Collapse, motion, useReducedMotion } from '../../motion/index.js';
25
- import { loops } from '../../motion/config.js';
26
- import { detectTextDirection, hasStrongCharacter } from '../../lib/text-direction.js';
24
+ import { Collapse } from '../../motion/index.js';
25
+ import { detectTextDirection, hasStrongCharacter, openingDirection } from '../../lib/text-direction.js';
27
26
  import { AgentAvatar } from './agent-avatar.js';
28
27
  import { ProviderBadge } from './provider-badge.js';
29
28
  import { Avatar, AvatarFallback, AvatarImage } from './avatar.js';
@@ -1403,22 +1402,13 @@ function SlashCommandsPlugin({ searchSlashCommands, onSelectCommand, onSlashType
1403
1402
  : labels.slashTitle;
1404
1403
  return (_jsxs(React.Fragment, { children: [startsGroup ? _jsx(SlashGroupHeader, { label: title, first: index === 0 }) : null, _jsxs("button", { id: `slash-typeahead-item-${index}`, type: "button", role: "option", tabIndex: -1, "aria-selected": selected,
1405
1404
  // Lexical scrolls the highlighted row into the max-h menu via this ref.
1406
- ref: option.setRefElement, className: cn('relative isolate flex w-full flex-col rounded-lg p-2 text-start text-sm in-data-[platform=mobile]:mb-0.5 in-data-[platform=mobile]:text-base', selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'), onClick: () => {
1405
+ ref: option.setRefElement, className: cn('flex w-full flex-col rounded-lg p-2 text-start text-sm in-data-[platform=mobile]:mb-0.5 in-data-[platform=mobile]:text-base', selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'), onClick: () => {
1407
1406
  setHighlightedIndex(index);
1408
1407
  selectOptionAndCleanUp(option);
1409
- }, onMouseEnter: () => setHighlightedIndex(index), children: [option.candidate.emphasis ? _jsx(SlashEmphasis, {}) : null, _jsxs("span", { children: ["/", option.candidate.name] }), _jsx("span", { className: "text-muted-foreground", children: option.candidate.description })] })] }, option.key));
1408
+ }, onMouseEnter: () => setHighlightedIndex(index), children: [_jsxs("span", { children: ["/", option.candidate.name] }), _jsx("span", { className: "text-muted-foreground", children: option.candidate.description })] })] }, option.key));
1410
1409
  })) }), document.body);
1411
1410
  } }));
1412
1411
  }
1413
- /** A tinted wash with a light band sweeping across it; still (tint only) under reduced motion. */
1414
- function SlashEmphasis() {
1415
- const reduceMotion = useReducedMotion();
1416
- return (_jsx(motion.span, { "aria-hidden": true, "data-slot": "slash-emphasis", className: "pointer-events-none absolute inset-0 -z-10 rounded-lg bg-primary/8", style: {
1417
- backgroundImage: 'linear-gradient(100deg, transparent 30%, color-mix(in oklab, var(--primary) 22%, transparent) 50%, transparent 70%)',
1418
- backgroundSize: '220% 100%',
1419
- backgroundRepeat: 'no-repeat',
1420
- }, initial: { backgroundPosition: '160% 0' }, animate: reduceMotion === true ? undefined : { backgroundPosition: ['160% 0', '-60% 0'] }, transition: { ...loops.shimmer, duration: 1.8 } }));
1421
- }
1422
1412
  function SlashGroupHeader({ label, first }) {
1423
1413
  return (_jsxs("div", { className: cn('flex items-center gap-2.5 px-2 pb-1 text-sm text-muted-foreground', first ? 'pt-1.5' : 'pt-3'), children: [_jsx("span", { children: label }), _jsx("span", { className: "h-px flex-1 bg-border", "aria-hidden": true })] }));
1424
1414
  }
@@ -1948,8 +1938,8 @@ const toggleHeading = (editor, tag) => editor.update(() => {
1948
1938
  * underline are deliberately absent because the message AST has no representation for them.
1949
1939
  */
1950
1940
  const editorTheme = {
1951
- // DirectionPlugin sets `dir` for bidi; `text-left` keeps lines left-aligned like Slack.
1952
- paragraph: 'm-0 text-left',
1941
+ // DirectionPlugin sets `dir` per block for bidi; the editor wrapper owns the message alignment.
1942
+ paragraph: 'm-0',
1953
1943
  text: {
1954
1944
  bold: 'font-semibold',
1955
1945
  italic: 'italic',
@@ -1970,7 +1960,7 @@ const editorTheme = {
1970
1960
  nested: { listitem: 'list-none' },
1971
1961
  },
1972
1962
  link: 'text-primary underline underline-offset-2',
1973
- code: 'my-1 block rounded bg-muted p-2 font-mono text-[0.9em]',
1963
+ code: 'my-1 block rounded bg-muted p-2 text-left font-mono text-[0.9em]',
1974
1964
  };
1975
1965
  const DOCUMENT_MARKDOWN_TRANSFORMERS = [
1976
1966
  BOLD_ITALIC_STAR,
@@ -2267,17 +2257,28 @@ function LinkDialog({ draft, labels, onClose, }) {
2267
2257
  }
2268
2258
  // ── Content direction (HAC #1045) ────────────────────────────────────────────
2269
2259
  const DIRECTION_PLUGIN_TAG = 'direction-plugin';
2270
- /** Survives empty composer / send remount so typing Hebrew again keeps RTL. */
2271
- let lastRememberedDirection = 'ltr';
2260
+ /**
2261
+ * Opener direction of the last non-empty draft. Survives an empty composer / send remount so
2262
+ * typing Hebrew again keeps RTL, and a Hebrew-led composer stays right-aligned while empty.
2263
+ */
2264
+ let lastRememberedDirection = null;
2272
2265
  /** Test-only: reset module-scoped remembered direction between cases. */
2273
2266
  function __resetLastRememberedDirectionForTests() {
2274
- lastRememberedDirection = 'ltr';
2267
+ lastRememberedDirection = null;
2275
2268
  }
2276
2269
  /**
2277
2270
  * Plain text used for word-vote direction: mentions, cycle triggers, stream
2278
2271
  * chips and link labels are excluded so `@Name` then Hebrew does not lock LTR.
2272
+ * A list contributes its top-level items only, so a nested sub-list cannot outvote it.
2279
2273
  */
2280
2274
  const plainTextForDirection = (node) => {
2275
+ // Items stay separate words, as the renderer joins them: `hello` + `please` must not fuse into one token.
2276
+ if ($isListNode(node)) {
2277
+ return node
2278
+ .getChildren()
2279
+ .map((item) => ($isElementNode(item) ? plainTextForDirection(item) : ''))
2280
+ .join(' ');
2281
+ }
2281
2282
  let out = '';
2282
2283
  for (const child of node.getChildren()) {
2283
2284
  if ($isMentionNode(child) ||
@@ -2286,6 +2287,8 @@ const plainTextForDirection = (node) => {
2286
2287
  $isCustomEmojiNode(child) ||
2287
2288
  $isLinkNode(child))
2288
2289
  continue;
2290
+ if ($isListNode(child))
2291
+ continue;
2289
2292
  if ($isTextNode(child)) {
2290
2293
  out += child.getTextContent();
2291
2294
  continue;
@@ -2301,9 +2304,10 @@ const plainTextForDirection = (node) => {
2301
2304
  * once mention display names enter the signal.
2302
2305
  *
2303
2306
  * Stamps the live DOM (HAC) and Lexical `setDirection` so caret alignment and
2304
- * serialized `direction` stay in sync.
2307
+ * serialized `direction` stay in sync. In chat mode (`onAlignmentChange` given) the first block
2308
+ * with a strong letter also decides one side for the whole draft, so later paragraphs never move it.
2305
2309
  */
2306
- function DirectionPlugin({ enabled = true }) {
2310
+ function DirectionPlugin({ enabled = true, onAlignmentChange, }) {
2307
2311
  const [editor] = useLexicalComposerContext();
2308
2312
  React.useEffect(() => {
2309
2313
  if (!enabled)
@@ -2311,6 +2315,18 @@ function DirectionPlugin({ enabled = true }) {
2311
2315
  const stampDirections = () => {
2312
2316
  const updates = [];
2313
2317
  editor.getEditorState().read(() => {
2318
+ const blocks = $getRoot()
2319
+ .getChildren()
2320
+ .flatMap((child) => {
2321
+ if (!$isListNode(child) && !$isParagraphNode(child))
2322
+ return [];
2323
+ return [{ child, signal: plainTextForDirection(child) }];
2324
+ });
2325
+ const opener = openingDirection(blocks.map((block) => block.signal));
2326
+ if (opener !== null)
2327
+ lastRememberedDirection = opener;
2328
+ const draftDirection = opener ?? lastRememberedDirection;
2329
+ onAlignmentChange?.(draftDirection === null ? null : draftDirection === 'rtl' ? 'right' : 'left');
2314
2330
  for (const child of $getRoot().getChildren()) {
2315
2331
  const dom = editor.getElementByKey(child.getKey());
2316
2332
  let dir;
@@ -2323,12 +2339,13 @@ function DirectionPlugin({ enabled = true }) {
2323
2339
  // Empty / "@" / "/" / mention-only blocks keep the last typed direction (HAC remount
2324
2340
  // memory) on the DOM only: a model update here would re-run the typeahead listener
2325
2341
  // mid-trigger and close the menu the composer's @ and / buttons just opened.
2326
- if (dom && dom.getAttribute('dir') !== lastRememberedDirection)
2342
+ if (dom &&
2343
+ lastRememberedDirection &&
2344
+ dom.getAttribute('dir') !== lastRememberedDirection)
2327
2345
  dom.setAttribute('dir', lastRememberedDirection);
2328
2346
  continue;
2329
2347
  }
2330
2348
  dir = detectTextDirection(signal);
2331
- lastRememberedDirection = dir;
2332
2349
  }
2333
2350
  else {
2334
2351
  continue;
@@ -2350,12 +2367,16 @@ function DirectionPlugin({ enabled = true }) {
2350
2367
  }, { tag: DIRECTION_PLUGIN_TAG });
2351
2368
  };
2352
2369
  stampDirections();
2353
- return editor.registerUpdateListener(({ tags }) => {
2370
+ const unregister = editor.registerUpdateListener(({ tags }) => {
2354
2371
  if (tags.has(DIRECTION_PLUGIN_TAG))
2355
2372
  return;
2356
2373
  stampDirections();
2357
2374
  });
2358
- }, [editor, enabled]);
2375
+ return () => {
2376
+ unregister();
2377
+ onAlignmentChange?.(null);
2378
+ };
2379
+ }, [editor, enabled, onAlignmentChange]);
2359
2380
  return null;
2360
2381
  }
2361
2382
  const editorNodes = [
@@ -2384,6 +2405,7 @@ function RichTextEditorInner({ value: _value, onChange, placeholder, searchMenti
2384
2405
  const documentImages = useDocumentImages(editor, documentMode);
2385
2406
  const documentContext = React.useMemo(() => (documentMode ? { options: documentMode, labels: documentLabels } : null), [documentMode, documentLabels]);
2386
2407
  const [linkDraft, setLinkDraft] = React.useState(null);
2408
+ const [alignment, setAlignment] = React.useState(null);
2387
2409
  const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides]);
2388
2410
  const showToolbar = toolbar && !disabled;
2389
2411
  const openLinkDialog = React.useCallback(() => setLinkDraft(readLinkDraft(editor)), [editor]);
@@ -2393,9 +2415,12 @@ function RichTextEditorInner({ value: _value, onChange, placeholder, searchMenti
2393
2415
  streamFallback: labels.streamFallback,
2394
2416
  }, children: _jsxs("div", { "data-slot": "rich-text-editor", className: cn('flex min-h-0 flex-col rounded-md border', className), children: [_jsx(Collapse, { open: showToolbar, children: _jsx(FormattingToolbar, { labels: labels, onLinkClick: openLinkDialog, documentMode: documentContext
2395
2417
  ? { ...documentContext, addFiles: documentImages.addFiles }
2396
- : undefined }) }), _jsx("div", { className: "relative min-h-0 flex-1 overflow-y-auto", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { "aria-label": ariaLabel, "aria-multiline": true, role: "textbox", className: "min-h-9 w-full resize-none px-3 py-1.5 text-sm outline-none in-data-[platform=mobile]:text-base disabled:cursor-not-allowed disabled:opacity-50", disabled: disabled }), placeholder: placeholder ? (_jsx("div", { className: "pointer-events-none absolute top-1.5 start-3 text-sm text-muted-foreground", children: placeholder })) : null, ErrorBoundary: PassthroughErrorBoundary }) }), autoFocus ? _jsx(AutoFocusPlugin, {}) : null, _jsx(HistoryPlugin, {}), _jsx(ListPlugin, {}), _jsx(ListTabIndentPlugin, {}), _jsx(LinkPlugin, { validateUrl: (url) => /^https?:\/\//i.test(url) }), _jsx(AutoLinkPlugin, { matchers: MATCHERS, excludeParents: AUTO_LINK_EXCLUDE_PARENTS }), _jsx(MarkdownShortcutPlugin, { transformers: documentMode ? DOCUMENT_MARKDOWN_TRANSFORMERS : MARKDOWN_TRANSFORMERS }), _jsx(PasteLinkOnSelectionPlugin, {}), linkDraft ? (_jsx(LinkDialog, { draft: linkDraft, labels: labels, onClose: () => setLinkDraft(null) })) : null, _jsx(OnChangePlugin, { onChange: (editorState) => {
2418
+ : undefined }) }), _jsx("div", { "data-align": documentMode ? undefined : (alignment ?? undefined), className: "group/align relative min-h-0 flex-1 overflow-y-auto", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { "aria-label": ariaLabel, "aria-multiline": true, role: "textbox", className: cn(
2419
+ // A document keeps its lines on the left; a chat draft aligns as one block by its
2420
+ // opener, and a right-aligned draft parks its (shrink-wrapped) lists on the right.
2421
+ documentMode ? 'text-left' : 'text-start', 'group-data-[align=left]/align:text-left group-data-[align=right]/align:text-right', 'group-data-[align=right]/align:[&>ol]:ml-auto group-data-[align=right]/align:[&>ul]:ml-auto group-data-[align=left]/align:[&>ol]:mr-auto group-data-[align=left]/align:[&>ul]:mr-auto', 'min-h-9 w-full resize-none px-3 py-1.5 text-sm outline-none in-data-[platform=mobile]:text-base disabled:cursor-not-allowed disabled:opacity-50'), disabled: disabled }), placeholder: placeholder ? (_jsx("div", { className: "pointer-events-none absolute top-1.5 start-3 text-sm text-muted-foreground group-data-[align=left]/align:start-auto group-data-[align=left]/align:left-3 group-data-[align=right]/align:start-auto group-data-[align=right]/align:right-3", children: placeholder })) : null, ErrorBoundary: PassthroughErrorBoundary }) }), autoFocus ? _jsx(AutoFocusPlugin, {}) : null, _jsx(HistoryPlugin, {}), _jsx(ListPlugin, {}), _jsx(ListTabIndentPlugin, {}), _jsx(LinkPlugin, { validateUrl: (url) => /^https?:\/\//i.test(url) }), _jsx(AutoLinkPlugin, { matchers: MATCHERS, excludeParents: AUTO_LINK_EXCLUDE_PARENTS }), _jsx(MarkdownShortcutPlugin, { transformers: documentMode ? DOCUMENT_MARKDOWN_TRANSFORMERS : MARKDOWN_TRANSFORMERS }), _jsx(PasteLinkOnSelectionPlugin, {}), linkDraft ? (_jsx(LinkDialog, { draft: linkDraft, labels: labels, onClose: () => setLinkDraft(null) })) : null, _jsx(OnChangePlugin, { onChange: (editorState) => {
2397
2422
  onChange(editorState.toJSON());
2398
- } }), _jsx(EnterSubmitPlugin, { onSubmit: onSubmit, sendOnEnter: sendOnEnter }), documentMode ? null : _jsx(CodeFenceOnThirdBacktickPlugin, {}), _jsx(InsertTextAtCaretPlugin, { request: insertTextAtCaret, onInserted: onInsertTextAtCaret }), _jsx(TypeaheadEditGatePlugin, {}), _jsx(MentionsPluginWithPortal, { searchMentions: searchMentions, onMentionSelect: onMentionSelect }), _jsx(SlashCommandsPlugin, { searchSlashCommands: searchSlashCommands, onSelectCommand: onSlashCommandSelect, onSlashTyped: onSlashTyped, labels: labels }), _jsx(EmojiTypeaheadPlugin, { searchEmojis: searchEmojis, labels: labels, onEmojiUsed: onEmojiUsed }), _jsx(EmojiShortcodeReplacePlugin, { resolveEmojiShortcode: resolveEmojiShortcode, onEmojiUsed: onEmojiUsed }), _jsx(StreamChipsTypeaheadPlugin, { searchStreams: searchStreams, labels: labels }), _jsx(CycleTriggerPlugin, { request: insertCycleTrigger, onInserted: onCycleTriggerInserted }), _jsx(CustomEmojiPlugin, { request: insertCustomEmoji, onInserted: onCustomEmojiInserted }), _jsx(SlashCommandChipPlugin, { commands: chipSlashCommands }), _jsx(DirectionPlugin, { enabled: detectContentDirection })] }) }) }));
2423
+ } }), _jsx(EnterSubmitPlugin, { onSubmit: onSubmit, sendOnEnter: sendOnEnter }), documentMode ? null : _jsx(CodeFenceOnThirdBacktickPlugin, {}), _jsx(InsertTextAtCaretPlugin, { request: insertTextAtCaret, onInserted: onInsertTextAtCaret }), _jsx(TypeaheadEditGatePlugin, {}), _jsx(MentionsPluginWithPortal, { searchMentions: searchMentions, onMentionSelect: onMentionSelect }), _jsx(SlashCommandsPlugin, { searchSlashCommands: searchSlashCommands, onSelectCommand: onSlashCommandSelect, onSlashTyped: onSlashTyped, labels: labels }), _jsx(EmojiTypeaheadPlugin, { searchEmojis: searchEmojis, labels: labels, onEmojiUsed: onEmojiUsed }), _jsx(EmojiShortcodeReplacePlugin, { resolveEmojiShortcode: resolveEmojiShortcode, onEmojiUsed: onEmojiUsed }), _jsx(StreamChipsTypeaheadPlugin, { searchStreams: searchStreams, labels: labels }), _jsx(CycleTriggerPlugin, { request: insertCycleTrigger, onInserted: onCycleTriggerInserted }), _jsx(CustomEmojiPlugin, { request: insertCustomEmoji, onInserted: onCustomEmojiInserted }), _jsx(SlashCommandChipPlugin, { commands: chipSlashCommands }), _jsx(DirectionPlugin, { enabled: detectContentDirection, onAlignmentChange: documentMode ? undefined : setAlignment })] }) }) }));
2399
2424
  }
2400
2425
  /**
2401
2426
  * Controlled rich-text editor built on Lexical.
package/dist/index.d.ts CHANGED
@@ -33,7 +33,7 @@ export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants
33
33
  export { Button, buttonVariants } from './components/ui/button.js';
34
34
  export { cn } from './lib/utils.js';
35
35
  export { HOME_TILE_COUNT, homeTileIndex } from './lib/home-tile.js';
36
- export { detectTextDirection, hasStrongCharacter, isNeutralToken } from './lib/text-direction.js';
36
+ export { detectTextDirection, hasStrongCharacter, isNeutralToken, openingDirection, } from './lib/text-direction.js';
37
37
  export { Checkbox } from './components/ui/checkbox.js';
38
38
  export { Clickable, clickableVariants, type ClickableProps } from './components/ui/clickable.js';
39
39
  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './components/ui/dialog.js';
package/dist/index.js CHANGED
@@ -38,7 +38,7 @@ export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants
38
38
  export { Button, buttonVariants } from './components/ui/button.js';
39
39
  export { cn } from './lib/utils.js';
40
40
  export { HOME_TILE_COUNT, homeTileIndex } from './lib/home-tile.js';
41
- export { detectTextDirection, hasStrongCharacter, isNeutralToken } from './lib/text-direction.js';
41
+ export { detectTextDirection, hasStrongCharacter, isNeutralToken, openingDirection, } from './lib/text-direction.js';
42
42
  export { Checkbox } from './components/ui/checkbox.js';
43
43
  export { Clickable, clickableVariants } from './components/ui/clickable.js';
44
44
  // prettier-ignore
@@ -26,3 +26,8 @@ export declare const hasStrongCharacter: (text: string) => boolean;
26
26
  * with a dominant-script override.
27
27
  */
28
28
  export declare const detectTextDirection: (text: string) => TextDirection;
29
+ /**
30
+ * Side a whole chat message aligns to: the word vote on the first block that has a strong
31
+ * letter (blank, digit-only and punctuation-only openers are skipped), or `null` when no block has one.
32
+ */
33
+ export declare const openingDirection: (blockTexts: Iterable<string>) => TextDirection | null;
@@ -117,3 +117,23 @@ export const detectTextDirection = (text) => {
117
117
  return 'ltr';
118
118
  return firstStrong;
119
119
  };
120
+ const LETTER = /\p{L}/u;
121
+ /** Like `hasStrongCharacter`, but digits and punctuation in RTL ranges (`١٢`, `׃`) do not count. */
122
+ const hasStrongLetter = (text) => {
123
+ for (const character of text) {
124
+ if (strengthOf(character) !== null && LETTER.test(character))
125
+ return true;
126
+ }
127
+ return false;
128
+ };
129
+ /**
130
+ * Side a whole chat message aligns to: the word vote on the first block that has a strong
131
+ * letter (blank, digit-only and punctuation-only openers are skipped), or `null` when no block has one.
132
+ */
133
+ export const openingDirection = (blockTexts) => {
134
+ for (const text of blockTexts) {
135
+ if (hasStrongLetter(text))
136
+ return detectTextDirection(text);
137
+ }
138
+ return null;
139
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamsai/ui",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Streams design system: shadcn/ui components on Base UI, design tokens (Tailwind v4) and a theme provider for React 19.",
5
5
  "license": "MIT",
6
6
  "repository": {