diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts b/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts index 032646235..b0b86c8db 100644 --- a/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts +++ b/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts @@ -125,3 +125,327 @@ export const disabledDemo = { ` }; + +export const editorCapabilitiesDemo = { + type: 'code', + code: `function EditorCapabilities() { + const composer = React.useRef(null); + + // Ungrouped items lead the menu; groups follow in the order they first + // appear. Icons, trailing badges, disabled rows and opaque \`data\` all work. + const ITEMS = [ + { + id: 'page', + label: 'This page', + type: 'page', + icon: , + data: { path: '/docs/ai-elements/prompt-input' } + }, + { + id: 'button', + label: 'Button', + type: 'component', + group: 'Components', + icon: , + data: { status: 'stable' } + }, + { + id: 'data-table', + label: 'DataTable', + type: 'component', + group: 'Components', + icon: , + data: { status: 'stable' } + }, + { + id: 'prompt-input', + label: 'PromptInput', + type: 'component', + group: 'Components', + icon: , + data: { status: 'beta' } + }, + { + id: 'u1', + label: 'Maya Chen', + type: 'user', + group: 'Users', + icon: + }, + { + id: 'u2', + label: 'Apsara Assistant', + type: 'user', + group: 'Users', + icon: , + trailing: Agent + }, + { + id: 'u3', + label: 'Dana Whitfield', + type: 'user', + group: 'Users', + icon: , + disabled: true + } + ]; + + const MAX_LENGTH = 280; + const [draft, setDraft] = React.useState({ markup: '', text: '', mentions: [] }); + + // getValue() seeds the readout with the restored draft before any edit. + React.useEffect(() => { + const message = composer.current?.getValue(); + if (message) setDraft(message); + }, []); + + const mono = { fontFamily: 'monospace', wordBreak: 'break-all' }; + + // A fixed footprint: the readout below grows as you type, and the docs page + // centres this box, so letting it change height would nudge the whole page. + return ( + + + setDraft({ markup, text: details.text, mentions: details.mentions }) + } + onSubmit={(message, event) => event.currentTarget.reset()} + > + + Promise.resolve( + refs.map(ref => ITEMS.find(item => item.id === ref.id)).filter(Boolean) + ) + } + emptyMessage="Nothing matches" + /> + + + + + + {draft.text.length}/{MAX_LENGTH} + + + + + + + text + {draft.text || '—'} + + markup (save this as the draft) + {draft.markup || '—'} + + mentions + {draft.mentions.length === 0 ? ( + + ) : ( + draft.mentions.map((mention, index) => ( + + {mention.type}:{mention.id} at [{mention.start}, {mention.end}] + {mention.data ? ' — data ' + JSON.stringify(mention.data) : ''} + + )) + )} + + + ); +}` +}; + +export const mentionsDemo = { + type: 'code', + code: `function MentionsPromptInput() { + const ITEMS = [ + { id: 'button', label: 'Button', type: 'component', group: 'Components' }, + { id: 'data-table', label: 'DataTable', type: 'component', group: 'Components' }, + { + id: 'prompt-input', + label: 'PromptInput', + type: 'component', + group: 'Components' + }, + { id: 'u1', label: 'Maya Chen', type: 'user', group: 'Users' }, + { + id: 'u2', + label: 'Apsara Assistant', + type: 'user', + group: 'Users', + trailing: Agent + }, + { id: 'u3', label: 'Dana Whitfield', type: 'user', group: 'Users' } + ]; + + const [sent, setSent] = React.useState(null); + + return ( + + { + setSent(message); + event.currentTarget.reset(); + }} + > + + + + + + + {sent ? ( + + text: {sent.text} + markup: {sent.markup} + + mentions: {sent.mentions.map(m => m.type + ':' + m.id).join(', ') || '—'} + + + ) : null} + + ); +}` +}; + +export const mentionsAsyncDemo = { + type: 'code', + code: `function AsyncMentionsPromptInput() { + const DIRECTORY = [ + { id: 'button', label: 'Button', type: 'component', group: 'Components' }, + { id: 'data-table', label: 'DataTable', type: 'component', group: 'Components' }, + { + id: 'prompt-input', + label: 'PromptInput', + type: 'component', + group: 'Components' + }, + { id: 'u1', label: 'Maya Chen', type: 'user', group: 'Users' }, + { id: 'u2', label: 'Ravi Iyer', type: 'user', group: 'Users' }, + { id: 'u3', label: 'Nia Okafor', type: 'user', group: 'Users' } + ]; + + // Stands in for your API. The signal is aborted when a request is superseded. + const search = (query, { signal }) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const q = query.toLowerCase(); + resolve(DIRECTORY.filter(item => item.label.toLowerCase().includes(q))); + }, 600); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + + return ( +
+ event.currentTarget.reset()} + > + + Promise.resolve( + refs + .map(ref => DIRECTORY.find(item => item.id === ref.id)) + .filter(Boolean) + ) + } + emptyMessage="Nothing by that name" + /> + + + + + +
+ ); +}` +}; + +export const insertMentionDemo = { + type: 'code', + code: `function InsertMentionPromptInput() { + const composer = React.useRef(null); + + // No here: typing @ does nothing, but chips added + // from code still render with their icon. + return ( +
+ event.currentTarget.reset()} + > + + + + + + + +
+ ); +}` +}; diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx b/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx index ac6348a43..e91aebefe 100644 --- a/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx +++ b/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx @@ -1,11 +1,11 @@ --- title: PromptInput -description: A composer for AI chat and standalone "Ask anything" boxes — auto-growing textarea, toolbar slots, and a status-aware submit button. +description: A composer for AI chat and standalone "Ask anything" boxes — auto-growing input, inline @-mention chips, toolbar slots, and a status-aware submit button. source: packages/raystack/components/prompt-input tag: new --- -import { preview, attachmentsDemo, statusDemo, controlledDemo, disabledDemo } from "./demo.ts"; +import { preview, attachmentsDemo, statusDemo, controlledDemo, disabledDemo, editorCapabilitiesDemo, mentionsDemo, mentionsAsyncDemo, insertMentionDemo } from "./demo.ts"; @@ -40,15 +40,21 @@ anything else. ### Root The `
` that holds the value and submit semantics. The whole frame reads -as one field: pressing its dead space — the header and footer padding, or the +as one field: clicking its empty space — the header and footer padding, or the gap between their controls — focuses the input, while anything you put in those -slots keeps its own press. (The slots sit outside hit testing, so those presses -land on the form, which does the focusing.) `PromptInput.Textarea` registers -itself as that input on mount; pass `inputRef` when you render a custom input -instead. A consumer `onMouseDown` that calls `preventDefault()` opts out. +slots still handles its own clicks. Whichever input part you render registers +itself on mount; pass `inputRef` if you render your own input instead. An +`onMouseDown` that calls `preventDefault()` opts out. +`onSubmit` gives you a message object rather than a plain string, so mentions +arrive with their ids and your own `data` next to the text: + + + + + ### Textarea The text field, built on `TextArea variant="borderless"`. It grows with its @@ -57,9 +63,43 @@ content up to a max-height cap (override with +### Editor + +The input to use when you want `@`-mention chips. Use it *instead of* +`Textarea`, not alongside it. + +It behaves like `Textarea` — Enter submits, Shift+Enter adds a newline, same +placeholder and same growth — and adds undo/redo plus the mention menu. + +Its line height is taller than `Textarea`'s, so a chip fits inside one line +without changing the composer's height as chips come and go. A single-row +`Editor` is a few pixels taller than a single-row `Textarea`. + +A few `Textarea` props work differently here. `readOnly` becomes `disabled`, +`onChange` becomes `onValueChange` on the root, `rows` becomes CSS +`min-height`, and its `ref` is an `HTMLDivElement`. + + + +### Mentions + +Sets up a trigger character and its data. It renders nothing on its own and +needs `PromptInput.Editor` — next to a `Textarea` it does nothing and warns in +development. + +Pass `items` for a list you already have, or `onSearch` to fetch results as the +user types. `onSearch` wins if you pass both. + + + + + +Each item sets its own `type`, so one `@` menu can mix kinds — the demo below +has a page, components and users. You get `type` back on submit. + ### Header -A slot row above the textarea, usually for `Chat.Attachment` previews. Hidden +A slot row above the input, usually for `Chat.Attachment` previews. Hidden while empty. ### Footer @@ -72,13 +112,114 @@ submit the form. ### Submit The trailing send button. It renders ↑ when idle, disables itself while the -input is empty, shows a spinner while `status="submitted"`, and flips to a +composer is empty, shows a spinner while `status="submitted"`, and flips to a stop square that calls `onStop` while `status="streaming"`. +## Value format + +A chip is written as `@[label](type:id)`, using whatever character the trigger +is: + +``` +check @[DataTable](component:data-table) with @[Maya Chen](user:u1) +``` + +`value`, `defaultValue` and the first argument of `onValueChange` all use this +format, so the obvious controlled wiring keeps chips intact: + +```tsx +const [value, setValue] = useState(''); + +``` + +Save this string as the user's draft. A few details: + +- `]`, `)` and `\` in a label are escaped with `\`, as are `:`, `)` and `\` in + a type or id. +- A line break is written as `\n`. +- Anything that does not parse stays as plain text — nothing throws, nothing + is dropped. +- Only `value` and `defaultValue` are parsed. Text you type or paste that + happens to look like `@[…](…)` stays as-is. +- Icons and `data` cannot be saved in a string, so restoring a draft needs + `resolveMentions` — see [Restoring a draft](#restoring-a-draft). + ## Examples +### Everything the Editor can do + +One composer with all of it turned on: a restored draft, a mention menu with +icons, a badge, a disabled row and sections, a length cap, buttons that drive +it from code, and a live readout of what you'd get on submit. + + + +Things worth trying in it: + +- **Chips behave like one character.** Backspace deletes a whole chip, arrow + keys step past it, and clicking one selects it. +- **Undo and redo** with Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z. Inserting a chip + undoes in one step. +- **Copy a chip and paste it back** — it comes back as a chip, id intact. + Paste it into a plain text field and you get `@Maya Chen`. +- **Paste formatted text** from a doc or a webpage and it arrives as plain + text. The editor has no bold, headings or lists to paste into. +- **Shift+Enter** adds a line break; Enter sends. +- **The length cap** counts a chip as its label and applies to pasted text + too, so you can't paste past it. +- **Multi-word search:** type `@maya ch` — the space keeps searching. Keep + typing past the point where nothing matches and the menu gives up and leaves + your text alone. +- **Escape** closes the menu and leaves what you typed as plain text. Delete a + character and it comes back. + +### Mentions + +Typing `@` opens a menu at the cursor. Picking an item drops a chip into the +text, followed by a space. The chip deletes in one press, arrow keys step over +it, and a message that is nothing but a chip still sends. + +The chip itself shows the item's icon and label — not the `@`. The trigger is +kept in the text you get on submit (`@Maya Chen`), where the mention boundary +has to survive being read by a model. + +Sections appear in the order their `group` first shows up in your data, so you +control the order by ordering `items`. Items without a `group` come first, and +a section with no matches disappears. + + + +### Async mentions and restoring a draft + +`onSearch` runs about 150 ms after the user stops typing and gets an +`AbortSignal` you can pass to `fetch`. Out-of-date responses are ignored, +loading rows show while a request is out, and a failed request shows the empty +state. + +A query can contain spaces so multi-word names stay searchable. Once a query +with a space stops matching anything, the menu closes and the text is left +alone. + +#### Restoring a draft + +A chip loaded from `defaultValue` starts with just its label, since a saved +string can't carry an icon or `data`. Pass `resolveMentions` to look those up +and fill them in — it also refreshes the label if the entity was renamed. +Lookups are batched and cached. If one fails or comes back empty, the chip +simply stays label-only; it is never removed. + + + +### Inserting a mention from code + +`actionsRef` gives you `focus`, `clear`, `getValue` and `insertMention`. +`insertMention` inserts at the cursor, or at the end if the editor isn't +focused. `ref` still points at the ``. + + + ### Attachments in the header Attachment previews are presentational (`Chat.Attachment`); file picking, @@ -91,7 +232,7 @@ drag-drop and uploads belong to your app. `status` reflects the request lifecycle your app manages: - **`idle`** — the resting state; the submit button shows the send arrow and - disables itself while the input is empty. + disables itself while the composer is empty. - **`submitted`** — the request is sent but nothing has streamed back yet; the button shows a spinner, Enter/submit is blocked, and clicking calls `onStop`. @@ -114,6 +255,17 @@ menus, mention pickers. +## When is the composer empty? + +A composer counts as empty when it has no chips and its text is only +whitespace. The submit button, the `data-empty` attribute and the placeholder +all use that same rule, so they always agree: + +- A message that is only a chip is not empty and sends. +- Whitespace on its own is empty. +- Leading and trailing whitespace is trimmed off the submitted message, + including the space added after a chip. Chips are never trimmed. + ## Accessibility - The root is a native ``; `PromptInput.Submit` is a real submit button, @@ -123,9 +275,17 @@ menus, mention pickers. - While a response is in flight the submit button becomes `type="button"` with an updated accessible name ("Stop response") so it can never resubmit the form. +- `Editor` and its mention menu use the standard combobox roles, so screen + readers announce the highlighted row as the user arrows through it. Focus + stays in the editor the whole time — the menu never takes it. +- While the menu is open, ↑ ↓ Enter Tab and Escape control the menu. Escape + closes the menu without also closing a surrounding `ChatPanel`, `Dialog` or + `Drawer`, so the draft survives. With the menu closed, those keys behave as + usual. +- Each chip has an accessible name, like `"mention: DataTable"`. - The composer frame carries the focus treatment — the border tints when a child has visible focus (`:has(:focus-visible)`) — while the borderless - textarea suppresses its own duplicate focus ring. + input suppresses its own duplicate focus ring. - Click-to-focus runs on `mousedown` and cancels the default focus shift, so focus never leaves the input and back again — a round trip that would dismiss anything anchored to it. Keyboard focus order is untouched. diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/props.ts b/apps/www/src/content/docs/ai-elements/prompt-input/props.ts index 62d20aa41..c88e7a582 100644 --- a/apps/www/src/content/docs/ai-elements/prompt-input/props.ts +++ b/apps/www/src/content/docs/ai-elements/prompt-input/props.ts @@ -1,24 +1,36 @@ import type React from 'react'; export interface PromptInputProps { - /** The composed text (controlled). */ + /** + * The draft, controlled. Uses the same string format as `onValueChange`. + * Most apps are better off leaving this uncontrolled. + */ value?: string; /** - * The initial text when uncontrolled. + * The starting draft when uncontrolled. * @defaultValue "" */ defaultValue?: string; - /** Called when the composed text changes. */ - onValueChange?: (value: string) => void; + /** + * Called on every change. The first argument can be passed straight back + * into `value` — chips included. + */ + onValueChange?: ( + markup: string, + details: { text: string; mentions: PromptInputMention[] } + ) => void; /** - * Called with the trimmed text when the prompt is submitted — Enter in the - * textarea or a click on `PromptInput.Submit`. Call - * `event.currentTarget.reset()` to clear the input after sending. + * Called with the trimmed message when the prompt is submitted — Enter in the + * input or a click on `PromptInput.Submit`. Call + * `event.currentTarget.reset()` to clear the composer after sending. */ - onSubmit?: (value: string, event: React.FormEvent) => void; + onSubmit?: ( + message: PromptInputMessage, + event: React.FormEvent + ) => void; /** * Called when `PromptInput.Submit` is pressed while `status` is @@ -42,15 +54,68 @@ export interface PromptInputProps { /** * The element the frame focuses when its own padding is clicked. - * `PromptInput.Textarea` registers itself here on mount; pass a ref of your - * own when you render a custom input instead. Whichever is set first wins. + * `PromptInput.Textarea` and `PromptInput.Editor` register themselves here on + * mount; pass a ref of your own when you render a custom input instead. + * Whichever is set first wins. */ inputRef?: React.RefObject; + /** + * Lets you drive the composer from code — `focus`, `clear`, + * `insertMention`, `getValue`. `ref` still points at the ``. + */ + actionsRef?: React.RefObject; + /** Custom CSS class names. */ className?: string; } +export interface PromptInputMessage { + /** The message as plain text, with each chip inlined as `@label`. */ + text: string; + + /** The message as a saveable string — pass it back into `value`. */ + markup: string; + + /** Chips in the order they appear. Offsets point into `text`. */ + mentions: PromptInputMention[]; +} + +export interface PromptInputMention { + id: string; + label: string; + /** The kind of thing this is — `"user"`, `"component"`, and so on. */ + type: string; + /** The character that opened the menu. */ + trigger: string; + /** Whatever `data` the item carried when it was picked. */ + data?: unknown; + /** Where the chip starts in `text`. */ + start: number; + /** Where the chip ends in `text`. */ + end: number; +} + +export interface PromptInputActions { + /** Focuses the input, putting the cursor at the end. */ + focus: () => void; + + /** Clears the composer. */ + clear: () => void; + + /** + * Inserts a chip at the cursor, or at the end if the editor isn't focused, + * followed by a space. Needs `PromptInput.Editor`. + */ + insertMention: ( + item: PromptInputMentionItem, + options?: { trigger?: string } + ) => void; + + /** The current draft, untrimmed. */ + getValue: () => PromptInputMessage; +} + export interface PromptInputTextareaProps { /** Placeholder shown while empty. @defaultValue "Write a message…" */ placeholder?: string; @@ -62,6 +127,87 @@ export interface PromptInputTextareaProps { className?: string; } +export interface PromptInputEditorProps { + /** Shown while the composer is empty. @defaultValue "Write a message…" */ + placeholder?: string; + + /** Disables just the editor. Follows the root `disabled` by default. */ + disabled?: boolean; + + /** + * Maximum length of the message as plain text, where a chip counts as its + * label. Applies to pasted and dictated text too, not just typing. + */ + maxLength?: number; + + /** @defaultValue true */ + spellCheck?: boolean; + + /** Custom CSS class names. */ + className?: string; +} + +export interface PromptInputMentionItem { + id: string; + + label: string; + + /** + * The kind of thing this is — `"user"`, `"component"`, and so on. Set per + * item, so one menu can mix kinds. + * @defaultValue "mention" + */ + type?: string; + + icon?: React.ReactNode; + + /** Shown at the end of the row — a badge, a shortcut, a timestamp. */ + trailing?: React.ReactNode; + + /** Section heading. Sections appear in the order they first show up. */ + group?: string; + + disabled?: boolean; + + /** Anything you want back on submit. Not saved into the draft string. */ + data?: unknown; +} + +export interface PromptInputMentionsProps { + /** The character that opens the menu. @defaultValue "@" */ + trigger?: string; + + /** A list you already have. Filtered and ranked on the label as you type. */ + items?: PromptInputMentionItem[]; + + /** + * Fetches results as the user types, about 150 ms after they stop. Pass + * `signal` to `fetch` to cancel outdated requests. Used instead of `items`. + */ + onSearch?: ( + query: string, + context: { trigger: string; signal: AbortSignal } + ) => Promise; + + /** + * Looks up the icon, trailing content and `data` for chips restored from + * `value` / `defaultValue`, which can't carry them, and refreshes their + * labels. Calls are batched and cached. + */ + resolveMentions?: ( + refs: Array<{ type: string; id: string; label: string }> + ) => Promise; + + /** Called when the menu opens or closes. */ + onOpenChange?: (open: boolean) => void; + + /** Shown when nothing matches. @defaultValue "No results" */ + emptyMessage?: React.ReactNode; + + /** Loading rows shown while `onSearch` is running. @defaultValue 3 */ + loadingRowCount?: number; +} + export interface PromptInputSubmitProps { /** * Replaces the status-derived icon (send arrow, spinner or stop square). diff --git a/packages/raystack/components/editor/__tests__/markup.test.ts b/packages/raystack/components/editor/__tests__/markup.test.ts new file mode 100644 index 000000000..eaf5076cb --- /dev/null +++ b/packages/raystack/components/editor/__tests__/markup.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; +import { + deriveDocDetails, + docFromMarkup, + docFromText, + isDocEmpty, + serializeMarkup, + serializeText +} from '../markup'; +import { isTriggerCharacter, serializeMention, trimDetails } from '../mention'; + +const derive = (markup: string) => deriveDocDetails(docFromMarkup(markup)); +const roundTrip = (markup: string) => serializeMarkup(docFromMarkup(markup)); + +describe('markup dialect', () => { + describe('triggers', () => { + it('accepts punctuation', () => { + for (const char of ['@', '/', '#', '!', '+', '~', '?']) { + expect(isTriggerCharacter(char)).toBe(true); + } + }); + + it('rejects word characters, whitespace and the dialect delimiters', () => { + for (const char of ['a', 'Z', '9', '_', ' ', '\n', '[', ']', '\\', '']) { + expect(isTriggerCharacter(char)).toBe(false); + } + }); + }); + + describe('round trips', () => { + const cases = [ + '', + 'hello', + 'hello world', + 'line one\nline two', + '@[Button](component:button)', + 'check @[Button](component:button) tokens', + '@[Button](component:button) @[Maya Chen](user:u1)', + '@[Button](component:button)@[Button](component:button)', + 'before\n@[Maya Chen](user:u1) after', + '/[Summarize](command:c1)', + '#[1234](issue:i1)' + ]; + + for (const markup of cases) { + it(`preserves ${JSON.stringify(markup)}`, () => { + expect(roundTrip(markup)).toBe(markup); + }); + } + + it('preserves labels holding the dialect delimiters', () => { + const markup = serializeMention({ + id: 'x1', + label: 'Weird ] name ) with \\ slashes', + type: 'component', + trigger: '@' + }); + expect(roundTrip(markup)).toBe(markup); + expect(derive(markup).mentions[0].label).toBe( + 'Weird ] name ) with \\ slashes' + ); + }); + + it('preserves ids and types holding a colon or a paren', () => { + const markup = serializeMention({ + id: 'urn:thing:1)', + label: 'Odd id', + type: 'a:b', + trigger: '@' + }); + expect(roundTrip(markup)).toBe(markup); + const mention = derive(markup).mentions[0]; + expect(mention.type).toBe('a:b'); + expect(mention.id).toBe('urn:thing:1)'); + }); + + it('survives a generated corpus', () => { + // A small LCG keeps the corpus reproducible when a case fails. + let seed = 987654321; + const next = (max: number) => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed % max; + }; + const alphabet = 'ab dz]()\\:@#/_.-19'; + const pick = (length: number) => + Array.from({ length }, () => alphabet[next(alphabet.length)]).join(''); + + for (let round = 0; round < 200; round += 1) { + const parts: string[] = []; + for (let index = 0; index < 1 + next(3); index += 1) { + parts.push(pick(1 + next(6))); + parts.push( + serializeMention({ + id: pick(1 + next(4)) || 'id', + label: pick(1 + next(6)) || 'label', + type: pick(1 + next(3)) || 'type', + trigger: '@' + }) + ); + } + const markup = parts.join(''); + expect(roundTrip(markup)).toBe(markup); + } + }); + }); + + describe('malformed markup degrades to literal text', () => { + const literals = [ + '@[foo](bar)', // no type:id separator + '@[foo]', // no reference + '@[foo](:b)', // empty type + '@[foo](a:)', // empty id + '@[](a:b)', // empty label + '[label](type:id)', // no trigger + 'see [docs](https://example.com) here', + 'a[foo](b:c)', // a word character is not a trigger + 'email@ [foo](b:c)', + '@[unclosed(a:b)' + ]; + + for (const markup of literals) { + it(`keeps ${JSON.stringify(markup)} literal`, () => { + const details = derive(markup); + expect(details.mentions).toHaveLength(0); + expect(details.text).toBe(markup); + }); + } + }); + + describe('derived text and offsets', () => { + it('inlines each label behind its trigger', () => { + const details = derive('check @[Maya Chen](user:u1) tokens'); + expect(details.text).toBe('check @Maya Chen tokens'); + expect(details.mentions).toEqual([ + { + id: 'u1', + label: 'Maya Chen', + type: 'user', + trigger: '@', + start: 6, + end: 16 + } + ]); + expect(details.text.slice(6, 16)).toBe('@Maya Chen'); + }); + + it('keeps duplicates in document order', () => { + const details = derive('@[A](p:1) then @[A](p:1) and @[B](p:2)'); + expect(details.mentions.map(mention => mention.id)).toEqual([ + '1', + '1', + '2' + ]); + expect(details.mentions[0].start).toBe(0); + expect(details.mentions[1].start).toBe(details.text.indexOf('@A', 1)); + }); + + it('turns hard breaks into newlines in both flavours', () => { + const details = derive('one\n@[A](p:1)\ntwo'); + expect(details.text).toBe('one\n@A\ntwo'); + expect(details.markup).toBe('one\n@[A](p:1)\ntwo'); + }); + }); + + describe('emptiness', () => { + it('is true for nothing and for whitespace', () => { + expect(isDocEmpty(docFromMarkup(''))).toBe(true); + expect(isDocEmpty(docFromMarkup(' '))).toBe(true); + expect(isDocEmpty(docFromMarkup('\n\n'))).toBe(true); + }); + + it('is false for a document holding nothing but a chip', () => { + expect(isDocEmpty(docFromMarkup('@[A](p:1)'))).toBe(false); + expect(isDocEmpty(docFromMarkup(' @[A](p:1) '))).toBe(false); + }); + + it('is false for text', () => { + expect(isDocEmpty(docFromMarkup('hi'))).toBe(false); + }); + }); + + describe('trimming', () => { + it('drops edge whitespace and re-bases offsets', () => { + const trimmed = trimDetails(derive(' hello @[A](p:1) ')); + expect(trimmed.text).toBe('hello @A'); + expect(trimmed.markup).toBe('hello @[A](p:1)'); + expect(trimmed.mentions[0].start).toBe(6); + expect(trimmed.text.slice(trimmed.mentions[0].start)).toBe('@A'); + }); + + it('keeps a chip that is the only content, including its trailing space', () => { + const trimmed = trimDetails(derive('@[A](p:1) ')); + expect(trimmed.markup).toBe('@[A](p:1)'); + expect(trimmed.text).toBe('@A'); + expect(trimmed.mentions).toHaveLength(1); + expect(trimmed.mentions[0].start).toBe(0); + }); + + it('re-bases offsets past a leading newline', () => { + const trimmed = trimDetails(derive('\n @[A](p:1)')); + expect(trimmed.mentions[0].start).toBe(0); + expect(trimmed.text).toBe('@A'); + }); + }); + + describe('plain text documents', () => { + it('never interprets markup', () => { + const details = deriveDocDetails(docFromText('@[A](p:1)')); + expect(details.mentions).toHaveLength(0); + expect(details.text).toBe('@[A](p:1)'); + }); + + it('splits newlines into hard breaks', () => { + expect(serializeText(docFromText('a\nb\nc'))).toBe('a\nb\nc'); + }); + }); +}); diff --git a/packages/raystack/components/editor/editor.module.css b/packages/raystack/components/editor/editor.module.css new file mode 100644 index 000000000..e410acf67 --- /dev/null +++ b/packages/raystack/components/editor/editor.module.css @@ -0,0 +1,176 @@ +/* The editing host. A contentEditable div grows with its content natively, so + there is no `field-sizing` here — that property applies to form controls. */ +.editor { + /* One line box has to be tall enough to hold a whole chip, or inserting one + would grow the composer and nudge the page under it. The small font's own + 16px line box is shorter than a chip's icon and trailing slots, so the + editor pairs the small font with the regular line height and sizes the chip + to match — the line box is then the same height whatever a line holds. */ + --editor-line-height: var(--rs-line-height-regular); + + box-sizing: border-box; + /* ProseMirror positions widget decorations against the editing host. */ + position: relative; + width: 100%; + outline: none; + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-word; + overflow-y: auto; + color: var(--rs-color-foreground-base-primary); + font-family: var(--rs-font-family); + font-size: var(--rs-font-size-small); + line-height: var(--editor-line-height); + letter-spacing: var(--rs-letter-spacing-small); +} + +.editor > p { + margin: 0; +} + +/* prosemirror-view ships a stylesheet the editor deliberately does not load — + it brings its own type, layout and selection treatment. Two of those rules + are load-bearing rather than cosmetic, so they are mirrored here. */ + +/* ProseMirror appends a zero-size placeholder after a paragraph that ends + in a `contentEditable="false"` node — a chip — so the browser can draw a + caret past it. An app-level `img { display: block }` reset (Tailwind's + preflight carries one) turns that placeholder into a block box, which reads + as a phantom empty line under the chip and swallows a Backspace. */ +.editor :global(img.ProseMirror-separator) { + display: inline !important; + border: none !important; + margin: 0 !important; +} + +/* A selected chip is a NodeSelection, so the text caret has to go away while + it is up, the way it does for any other selected object. */ +.editor:global(.ProseMirror-hideselection) { + caret-color: transparent; +} + +.editor:global(.ProseMirror-hideselection) *::selection { + background: transparent; +} + +/* Read from the same emptiness predicate as `data-empty` — a ProseMirror-empty + paragraph still holds a trailing
, so `:empty` would never match. */ +.placeholder::before { + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + color: var(--rs-color-foreground-base-tertiary); +} + +/* The subtle fill on the typed trigger and its query while the menu is open, so + a pending mention reads as pending rather than as ordinary text. */ +.activeSuggestion { + border-radius: var(--rs-radius-2); + background: var(--rs-color-background-base-primary-hover); +} + +/* Chip. Chip's tokens on the editor's own type scale, so the line box does not + jump, and inert — removal is editing-only, so there is no hover or active + treatment to advertise a press that does nothing. */ +.mention { + display: inline-flex; + align-items: center; + gap: var(--rs-space-1); + box-sizing: border-box; + /* Fixed to the line box's height, so nothing the chip holds can change the + composer's height as it lands: an icon portals in one commit after the chip + itself, and a consumer's trailing badge is taller than the label. */ + height: var(--editor-line-height); + max-width: 16rem; + padding: 0 var(--rs-space-2); + border-radius: var(--rs-radius-2); + background: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-primary); + font-size: inherit; + line-height: inherit; + letter-spacing: inherit; + /* Top-aligned rather than baseline-aligned: an inline-flex box takes its + baseline from its *first* flex item, so a chip with an icon would hang off + the icon's box and ride above the surrounding text. Its height matches the + line box, so aligning the two tops puts the label on the text's baseline. */ + vertical-align: top; + white-space: nowrap; + user-select: none; + cursor: default; +} + +.mentionLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mentionIcon, +.mentionTrailing { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--rs-color-foreground-base-secondary); +} + +/* The slots are portal targets and stay in the DOM even with nothing in them. */ +.mentionIcon:empty, +.mentionTrailing:empty { + display: none; +} + +.mentionIcon > svg, +.mentionTrailing > svg { + width: var(--rs-space-4); + height: var(--rs-space-4); +} + +/* NodeSelection — clicking a chip selects it as one unit. */ +.mention[data-selected] { + outline: var(--rs-focus-ring); + outline-offset: 1px; +} + +.editor[data-disabled] .mention { + opacity: 0.7; +} + +/* Suggestion menu. Positioned at the caret but sized to the composer: a caret + is a zero-width anchor, so `--anchor-width` is useless and the width has to + be stated. The guards keep it usable in narrow panels and wide layouts. */ +.suggestionMenu { + box-sizing: border-box; + width: var(--suggestion-menu-width, 20rem); + min-width: 240px; + max-width: min(var(--available-width), 420px); + max-height: 320px; + overflow-y: auto; + padding: var(--rs-space-2); +} + +.suggestionGroup + .suggestionGroup { + margin-top: var(--rs-space-2); +} + +.suggestionGroupLabel { + padding: var(--rs-space-2) var(--rs-space-3); + color: var(--rs-color-foreground-base-tertiary); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + font-weight: var(--rs-font-weight-medium); +} + +.suggestionRow { + cursor: pointer; +} + +.suggestionEmpty { + padding: var(--rs-space-3); + color: var(--rs-color-foreground-base-tertiary); +} + +.suggestionLoadingRow { + padding: var(--rs-space-3); +} diff --git a/packages/raystack/components/editor/index.ts b/packages/raystack/components/editor/index.ts new file mode 100644 index 000000000..4e9491cf2 --- /dev/null +++ b/packages/raystack/components/editor/index.ts @@ -0,0 +1,41 @@ +export { default as editorStyles } from './editor.module.css'; +export { + deriveDocDetails, + docFromMarkup, + docFromText, + type EditorDocDetails, + isDocEmpty, + serializeMarkup, + serializeText +} from './markup'; +// ProseMirror-free, so `PromptInput`'s root, textarea and mention registry can +// import straight from here without dragging the engine into their graph. +export { + type EditorMention, + isTriggerCharacter, + type MentionAttrs, + mentionKey, + serializeMention, + trimDetails +} from './mention'; +export type { MentionPortal } from './mention-node-view'; +export { editorSchema } from './schema'; +export { + type SuggestionAnchor, + type SuggestionGroup, + SuggestionMenu, + type SuggestionMenuItem, + type SuggestionMenuProps, + suggestionOptionId +} from './suggestion-menu'; +export { + dismissSuggestion, + insertMention, + type SuggestionState +} from './suggestion-plugin'; +export { + type EditorActions, + type UseEditorOptions, + type UseEditorResult, + useEditor +} from './use-editor'; diff --git a/packages/raystack/components/editor/markup.ts b/packages/raystack/components/editor/markup.ts new file mode 100644 index 000000000..09d526123 --- /dev/null +++ b/packages/raystack/components/editor/markup.ts @@ -0,0 +1,298 @@ +import { Fragment, type Node as PMNode, Slice } from 'prosemirror-model'; +import { + type EditorMention, + isTriggerCharacter, + type MentionAttrs, + serializeMention +} from './mention'; +import { + editorSchema, + hardBreakType, + mentionType, + paragraphType +} from './schema'; + +export interface EditorDocDetails { + /** Round-trippable markup — `"check @[DataTable](component:data-table)"`. */ + markup: string; + /** Plain text with each label inlined behind its trigger. */ + text: string; + /** Document order; duplicates preserved. */ + mentions: EditorMention[]; + /** No mentions, and text that trims to `""`. */ + empty: boolean; +} + +interface MentionMatch { + attrs: MentionAttrs; + /** Index just past the closing `)`. */ + next: number; +} + +/** + * Reads `X[label](type:id)` at `start`, where `X` is the trigger. Returns null + * for anything malformed so the caller can keep the characters as literal text. + */ +function readMention(source: string, start: number): MentionMatch | null { + const trigger = source[start]; + if (!isTriggerCharacter(trigger) || source[start + 1] !== '[') return null; + + let index = start + 2; + let label = ''; + while (index < source.length) { + const char = source[index]; + if (char === '\\' && index + 1 < source.length) { + label += source[index + 1]; + index += 2; + continue; + } + if (char === ']') break; + if (char === '\n') return null; + label += char; + index += 1; + } + if (source[index] !== ']' || source[index + 1] !== '(') return null; + index += 2; + + let type = ''; + let id = ''; + let separated = false; + while (index < source.length) { + const char = source[index]; + if (char === '\\' && index + 1 < source.length) { + if (separated) id += source[index + 1]; + else type += source[index + 1]; + index += 2; + continue; + } + if (char === ')') break; + if (char === '\n') return null; + if (char === ':' && !separated) { + separated = true; + index += 1; + continue; + } + if (separated) id += char; + else type += char; + index += 1; + } + if (source[index] !== ')') return null; + if (!separated || !label || !type || !id) return null; + + return { attrs: { id, label, type, trigger }, next: index + 1 }; +} + +/** Inline content for a plain string — newlines become hard breaks. */ +export function inlineFragmentFromText(text: string): Fragment { + const nodes: PMNode[] = []; + const lines = text.split('\n'); + lines.forEach((line, index) => { + if (index > 0) nodes.push(hardBreakType.create()); + if (line) nodes.push(editorSchema.text(line)); + }); + return Fragment.fromArray(nodes); +} + +/** + * Parses the markup dialect into a document. Called only for `value` / + * `defaultValue` — never for typed or pasted input, so ordinary prose that + * happens to contain `@[…](…)` stays literal while it is being written. + */ +export function docFromMarkup(markup: string): PMNode { + const nodes: PMNode[] = []; + let literal = ''; + let index = 0; + + const flush = () => { + if (!literal) return; + nodes.push(editorSchema.text(literal)); + literal = ''; + }; + + while (index < markup.length) { + const char = markup[index]; + + if (char === '\n') { + flush(); + nodes.push(hardBreakType.create()); + index += 1; + continue; + } + + if (markup[index + 1] === '[' && isTriggerCharacter(char)) { + const match = readMention(markup, index); + if (match) { + flush(); + nodes.push(mentionType.create(match.attrs)); + index = match.next; + continue; + } + } + + literal += char; + index += 1; + } + flush(); + + return editorSchema.topNodeType.create( + null, + paragraphType.create(null, Fragment.fromArray(nodes)) + ); +} + +/** A document holding a plain string, with no markup interpretation at all. */ +export function docFromText(text: string): PMNode { + return editorSchema.topNodeType.create( + null, + paragraphType.create(null, inlineFragmentFromText(text)) + ); +} + +/** Everything the host needs from a document, in one walk. */ +export function deriveDocDetails(doc: PMNode): EditorDocDetails { + let markup = ''; + let text = ''; + const mentions: EditorMention[] = []; + + doc.descendants(node => { + if (node.type === mentionType) { + const attrs = node.attrs as MentionAttrs; + const label = `${attrs.trigger}${attrs.label}`; + mentions.push({ + ...attrs, + start: text.length, + end: text.length + label.length + }); + markup += serializeMention(attrs); + text += label; + return false; + } + if (node.type === hardBreakType) { + markup += '\n'; + text += '\n'; + return false; + } + if (node.isText) { + markup += node.text ?? ''; + text += node.text ?? ''; + return false; + } + return true; + }); + + return { + markup, + text, + mentions, + empty: mentions.length === 0 && text.trim() === '' + }; +} + +/** The `text/plain` clipboard flavour for a copied range. */ +export function textFromFragment(fragment: Fragment): string { + let text = ''; + const walk = (content: Fragment) => { + content.forEach(node => { + if (node.type === mentionType) { + const attrs = node.attrs as MentionAttrs; + text += `${attrs.trigger}${attrs.label}`; + return; + } + if (node.type === hardBreakType) { + text += '\n'; + return; + } + if (node.isText) { + text += node.text ?? ''; + return; + } + if (node.isBlock && text) text += '\n'; + walk(node.content); + }); + }; + walk(fragment); + return text; +} + +export function serializeMarkup(doc: PMNode): string { + return deriveDocDetails(doc).markup; +} + +export function serializeText(doc: PMNode): string { + return deriveDocDetails(doc).text; +} + +/** + * The length of the derived text, without building it. Read on every + * transaction by the `maxLength` filter, which only ever wanted the number. + */ +export function textLength(doc: PMNode): number { + let length = 0; + doc.descendants(node => { + if (node.type === mentionType) { + const attrs = node.attrs as MentionAttrs; + length += attrs.trigger.length + attrs.label.length; + return false; + } + if (node.type === hardBreakType) { + length += 1; + return false; + } + if (node.isText) { + length += node.text?.length ?? 0; + return false; + } + return true; + }); + return length; +} + +/** + * The emptiness predicate, without building the markup and the mention list + * `deriveDocDetails` would. Read by the placeholder decoration on every state + * change, so it walks only as far as the first piece of content. + * + * Equivalent to `deriveDocDetails(doc).empty`: the concatenated text trims to + * `""` exactly when every text node does, and a hard break contributes only a + * newline. + */ +export function isDocEmpty(doc: PMNode): boolean { + let empty = true; + doc.descendants(node => { + if (!empty) return false; + if (node.type === mentionType) { + empty = false; + return false; + } + if (node.type === hardBreakType) return false; + if (node.isText) { + if ((node.text ?? '').trim() !== '') empty = false; + return false; + } + return true; + }); + return empty; +} + +/** + * Collapses a pasted slice to the inline content this schema allows: block + * boundaries become hard breaks, mentions survive with their ids, everything + * else arrives as text because the schema has nowhere else to put it. + */ +export function flattenToInlineSlice(slice: Slice): Slice { + const nodes: PMNode[] = []; + + const walk = (fragment: Fragment) => { + fragment.forEach(child => { + if (child.isInline) { + nodes.push(child.mark([])); + return; + } + if (nodes.length) nodes.push(hardBreakType.create()); + walk(child.content); + }); + }; + walk(slice.content); + + return new Slice(Fragment.fromArray(nodes), 0, 0); +} diff --git a/packages/raystack/components/editor/mention-node-view.ts b/packages/raystack/components/editor/mention-node-view.ts new file mode 100644 index 000000000..9a09a6f10 --- /dev/null +++ b/packages/raystack/components/editor/mention-node-view.ts @@ -0,0 +1,113 @@ +import type { Node as PMNode } from 'prosemirror-model'; +import type { NodeView } from 'prosemirror-view'; +import styles from './editor.module.css'; +import type { MentionAttrs } from './mention'; +import { mentionType } from './schema'; + +/** + * One live chip. The node view owns the element and writes the label into it + * synchronously — there is never an empty chip frame — while React portals the + * consumer's `icon` and `trailing` nodes into the two slots, so those render in + * the host tree and see Theme and any other provider context. + */ +export interface MentionPortal { + /** Stable React key for the lifetime of this node view. */ + id: number; + attrs: MentionAttrs; + iconTarget: HTMLElement; + trailingTarget: HTMLElement; +} + +export interface MentionPortalRegistry { + add: (portal: MentionPortal) => void; + update: (id: number, attrs: MentionAttrs) => void; + remove: (id: number) => void; +} + +let nextPortalId = 0; + +export class MentionNodeView implements NodeView { + readonly dom: HTMLElement; + + private readonly id = (nextPortalId += 1); + private readonly label: HTMLElement; + private readonly iconTarget: HTMLElement; + private readonly trailingTarget: HTMLElement; + private readonly registry: MentionPortalRegistry; + + constructor(node: PMNode, registry: MentionPortalRegistry) { + this.registry = registry; + + const root = document.createElement('span'); + root.className = styles.mention; + root.contentEditable = 'false'; + root.draggable = false; + root.setAttribute('data-mention', ''); + + this.iconTarget = document.createElement('span'); + this.iconTarget.className = styles.mentionIcon; + this.iconTarget.setAttribute('aria-hidden', 'true'); + + this.label = document.createElement('span'); + this.label.className = styles.mentionLabel; + + this.trailingTarget = document.createElement('span'); + this.trailingTarget.className = styles.mentionTrailing; + + root.append(this.iconTarget, this.label, this.trailingTarget); + this.dom = root; + + this.write(node.attrs as MentionAttrs); + registry.add({ + id: this.id, + attrs: node.attrs as MentionAttrs, + iconTarget: this.iconTarget, + trailingTarget: this.trailingTarget + }); + } + + private write(attrs: MentionAttrs) { + // The chip reads as the entity, not as the syntax that picked it: the + // trigger stays in the serialized text and markup, where the mention + // boundary has to survive, but the pill itself carries the label alone. + this.label.textContent = attrs.label; + this.dom.setAttribute('data-mention-id', attrs.id); + this.dom.setAttribute('data-mention-type', attrs.type); + this.dom.setAttribute('data-mention-trigger', attrs.trigger); + this.dom.setAttribute('aria-label', `mention: ${attrs.label}`); + this.dom.title = attrs.label; + } + + update(node: PMNode) { + if (node.type !== mentionType) return false; + this.write(node.attrs as MentionAttrs); + this.registry.update(this.id, node.attrs as MentionAttrs); + return true; + } + + selectNode() { + this.dom.setAttribute('data-selected', ''); + } + + deselectNode() { + this.dom.removeAttribute('data-selected'); + } + + /** + * Swallowing `dragstart` is what keeps a chip from being dropped into the + * middle of a word; a text selection that happens to contain one still + * drags, which is the platform behavior. + */ + stopEvent(event: Event) { + return event.type === 'dragstart'; + } + + /** React writes into the slots; those mutations are never document edits. */ + ignoreMutation() { + return true; + } + + destroy() { + this.registry.remove(this.id); + } +} diff --git a/packages/raystack/components/editor/mention.ts b/packages/raystack/components/editor/mention.ts new file mode 100644 index 000000000..07a74e02a --- /dev/null +++ b/packages/raystack/components/editor/mention.ts @@ -0,0 +1,81 @@ +/** + * The parts of the mention model that are pure strings and offsets — no + * ProseMirror. Kept in its own module so `PromptInput`'s root, its textarea and + * the mention registry can share the vocabulary without pulling the editor + * engine into their module graph. + */ + +/** Attributes carried by a mention node. All of them survive serialization. */ +export interface MentionAttrs { + id: string; + label: string; + /** Entity kind — `"project"`, `"user"`, … */ + type: string; + /** The character that opened the menu this mention was picked from. */ + trigger: string; +} + +/** + * A mention as it appears in a value change or a submitted message. Offsets + * index into the derived plain text. + */ +export interface EditorMention extends MentionAttrs { + start: number; + end: number; +} + +/** Cache key for the item store that backs a chip's icon, trailing and data. */ +export function mentionKey(trigger: string, type: string, id: string): string { + return `${trigger}|${type}|${id}`; +} + +/** + * A trigger is a single ASCII punctuation character. `[`, `]` and `\` are + * excluded because the dialect uses them as delimiters, and `_` because it + * reads as a word character — so a bare markdown link (`[label](a:b)`) and a + * snake_cased word are never mistaken for a mention. + */ +const TRIGGER_PATTERN = new RegExp( + '^[\\u0021-\\u002F\\u003A-\\u0040\\u005E\\u0060\\u007B-\\u007E]$' +); + +export function isTriggerCharacter(char: string): boolean { + return char.length === 1 && TRIGGER_PATTERN.test(char); +} + +function escapeLabel(value: string): string { + return value.replace(/[\\\])]/g, match => `\\${match}`); +} + +function escapeRef(value: string): string { + return value.replace(/[\\:)]/g, match => `\\${match}`); +} + +/** Serializes one mention — `@[label](type:id)`. */ +export function serializeMention(attrs: MentionAttrs): string { + return `${attrs.trigger}[${escapeLabel(attrs.label)}](${escapeRef( + attrs.type + )}:${escapeRef(attrs.id)})`; +} + +/** + * Drops whitespace at the document edges — including the space auto-inserted + * after a chip — while leaving mentions alone, then re-bases the offsets. A + * chip is never at an edge in the whitespace sense, so trimming can only ever + * remove text. + */ +export function trimDetails< + T extends { markup: string; text: string; mentions: EditorMention[] } +>(details: T): T { + const leading = details.text.length - details.text.trimStart().length; + return { + ...details, + markup: details.markup.trim(), + text: details.text.trim(), + mentions: details.mentions.map(mention => ({ + ...mention, + start: mention.start - leading, + end: mention.end - leading + })) + }; +} diff --git a/packages/raystack/components/editor/schema.ts b/packages/raystack/components/editor/schema.ts new file mode 100644 index 000000000..db1ba811e --- /dev/null +++ b/packages/raystack/components/editor/schema.ts @@ -0,0 +1,79 @@ +import { Schema } from 'prosemirror-model'; +import type { MentionAttrs } from './mention'; + +/** + * A deliberately tiny schema: one paragraph of text, hard breaks, and atomic + * mentions. Nothing else, so pasted HTML sanitizes to plain text for free — + * there is no mark or block the parser could keep. + */ +export const editorSchema = new Schema({ + nodes: { + doc: { content: 'paragraph' }, + + paragraph: { + content: 'inline*', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0] + }, + + text: { group: 'inline' }, + + hardBreak: { + inline: true, + group: 'inline', + selectable: false, + parseDOM: [{ tag: 'br' }], + toDOM: () => ['br'] + }, + + mention: { + inline: true, + group: 'inline', + // Atomic: the cursor never enters it, so it deletes and moves as one unit. + atom: true, + selectable: true, + // ProseMirror makes inline atoms draggable by default, which would let a + // chip be dropped into the middle of a word. + draggable: false, + attrs: { + id: {}, + label: {}, + type: { default: 'mention' }, + trigger: { default: '@' } + }, + parseDOM: [ + { + tag: 'span[data-mention-id]', + getAttrs: dom => { + const el = dom as HTMLElement; + return { + id: el.getAttribute('data-mention-id') ?? '', + label: el.getAttribute('data-mention-label') ?? el.textContent, + type: el.getAttribute('data-mention-type') ?? 'mention', + trigger: el.getAttribute('data-mention-trigger') ?? '@' + }; + } + } + ], + // Only used for the clipboard's `text/html` flavour — on screen the node + // view owns the element. Pasting this back restores the chip with its id. + toDOM: node => { + const { id, label, type, trigger } = node.attrs as MentionAttrs; + return [ + 'span', + { + 'data-mention-id': id, + 'data-mention-label': label, + 'data-mention-type': type, + 'data-mention-trigger': trigger + }, + `${trigger}${label}` + ]; + } + } + } +}); + +export const mentionType = editorSchema.nodes.mention; +export const hardBreakType = editorSchema.nodes.hardBreak; +export const paragraphType = editorSchema.nodes.paragraph; diff --git a/packages/raystack/components/editor/suggestion-menu.tsx b/packages/raystack/components/editor/suggestion-menu.tsx new file mode 100644 index 000000000..9bde404c3 --- /dev/null +++ b/packages/raystack/components/editor/suggestion-menu.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { CSSProperties, ReactNode } from 'react'; +import { Cell } from '../menu/cell'; +import { Popover } from '../popover'; +import { Skeleton } from '../skeleton'; +import styles from './editor.module.css'; + +export interface SuggestionMenuItem { + id: string; + label: string; + type?: string; + icon?: ReactNode; + trailing?: ReactNode; + group?: string; + disabled?: boolean; + data?: unknown; +} + +/** Rendered in first-appearance order; the leading group has no label. */ +export interface SuggestionGroup { + label?: string; + items: SuggestionMenuItem[]; +} + +/** A zero-width caret rect, re-measured by the positioner as the caret moves. */ +export interface SuggestionAnchor { + getBoundingClientRect: () => DOMRect; + /** + * The editing host. A virtual element has nothing for the positioner to + * observe on its own, so without this the menu would not follow the trigger + * when the composer grows a line or its scroll ancestors move. + */ + contextElement?: Element; +} + +export interface SuggestionMenuProps { + open: boolean; + anchor: SuggestionAnchor | null; + /** Listbox id, referenced by the editor's `aria-controls`. */ + id: string; + groups: SuggestionGroup[]; + /** Index into the flattened item list, or -1. */ + highlightedIndex: number; + onHighlightChange: (index: number) => void; + onSelect: (item: SuggestionMenuItem, index: number) => void; + onOpenChange: (open: boolean) => void; + loading?: boolean; + /** @defaultValue 3 */ + loadingRowCount?: number; + /** @defaultValue "No results" */ + emptyMessage?: ReactNode; + /** Width the popup takes, in pixels — the composer frame's width. */ + width?: number; + 'aria-label'?: string; +} + +/** Row ids are derived, so the editor can name the highlighted one. */ +export function suggestionOptionId(listId: string, index: number): string { + return `${listId}-option-${index}`; +} + +export function SuggestionMenu({ + open, + anchor, + id, + groups, + highlightedIndex, + onHighlightChange, + onSelect, + onOpenChange, + loading = false, + loadingRowCount = 3, + emptyMessage = 'No results', + width, + 'aria-label': ariaLabel = 'Suggestions' +}: SuggestionMenuProps) { + const total = groups.reduce((count, group) => count + group.items.length, 0); + let cursor = -1; + + return ( + + +
+ {loading && total === 0 + ? Array.from({ length: loadingRowCount }).map((_, index) => ( + + )) + : null} + + {!loading && total === 0 ? ( +
{emptyMessage}
+ ) : null} + + {groups.map(group => ( +
+ {group.label ? ( +
+ {group.label} +
+ ) : null} + {group.items.map(item => { + cursor += 1; + const index = cursor; + const highlighted = index === highlightedIndex; + return ( + { + if (!item.disabled) onHighlightChange(index); + }} + // Keep the caret: a press inside the menu must not move + // focus out of the editor. + onPointerDown={event => event.preventDefault()} + onClick={() => { + if (!item.disabled) onSelect(item, index); + }} + > + {item.label} + + ); + })} +
+ ))} +
+
+
+ ); +} + +SuggestionMenu.displayName = 'SuggestionMenu'; diff --git a/packages/raystack/components/editor/suggestion-plugin.ts b/packages/raystack/components/editor/suggestion-plugin.ts new file mode 100644 index 000000000..11e96bc29 --- /dev/null +++ b/packages/raystack/components/editor/suggestion-plugin.ts @@ -0,0 +1,296 @@ +import { + type EditorState, + Plugin, + PluginKey, + TextSelection +} from 'prosemirror-state'; +import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view'; +import styles from './editor.module.css'; +import type { MentionAttrs } from './mention'; +import { editorSchema, mentionType } from './schema'; + +/** The active trigger and the query the user is typing after it. */ +export interface SuggestionState { + trigger: string; + /** Text between the trigger and the caret. May contain spaces. */ + query: string; + /** Document position of the trigger character. */ + from: number; + /** Document position of the caret — the end of the query. */ + to: number; +} + +interface SuggestionPluginState { + active: SuggestionState | null; + /** + * A range the user dismissed with Escape or the space-cancel rule. Blocks + * re-detection until the query changes, so the menu does not spring back. + */ + dismissed: { from: number; query: string } | null; +} + +export interface SuggestionPluginOptions { + /** Trigger characters that are currently registered. */ + getTriggers: () => string[]; + /** Notified whenever the active query changes. */ + onStateChange: (state: SuggestionState | null) => void; + /** + * Called for every keydown while a query is active. Return true to consume + * the key — the handler owns `preventDefault` and `stopPropagation`. + */ + onKeyDown: (event: KeyboardEvent, state: SuggestionState) => boolean; +} + +export const suggestionPluginKey = new PluginKey( + 'apsara-suggestion' +); + +/** + * Stands in for atoms and hard breaks while scanning text, so a chip or a line + * break reads as one non-word character and ends a query. + */ +const OBJECT = ''; + +/** Nothing plausible is a mention query past this many characters. */ +const MAX_QUERY_LENGTH = 120; + +function isBoundary(char: string): boolean { + return char === '' || char === OBJECT || /\s/.test(char); +} + +/** + * Looks backwards from the caret for a trigger at a word boundary. Stops at + * whitespace, so only a single-word query can *start* a menu — a query that + * already contains a space is carried forward by the active state instead. + */ +function detect( + selection: TextSelection, + triggers: string[] +): SuggestionState | null { + if (!selection.empty || triggers.length === 0) return null; + const $from = selection.$from; + if (!$from.parent.isTextblock) return null; + + const before = $from.parent.textBetween( + 0, + $from.parentOffset, + OBJECT, + OBJECT + ); + const blockStart = $from.start(); + const stop = Math.max(0, before.length - MAX_QUERY_LENGTH); + + for (let index = before.length - 1; index >= stop; index -= 1) { + const char = before[index]; + if (char === OBJECT) break; + if (triggers.includes(char)) { + if (!isBoundary(index === 0 ? '' : before[index - 1])) continue; + return { + trigger: char, + query: before.slice(index + 1), + from: blockStart + index, + to: blockStart + before.length + }; + } + if (/\s/.test(char)) break; + } + + return null; +} + +/** + * Carries an active query across a transaction. Returns null when the query + * can no longer be extended — the trigger was deleted, the caret left the + * range, or a chip or line break landed inside it. + */ +function carry( + active: SuggestionState, + from: number, + state: EditorState +): SuggestionState | null { + if (!state.selection.empty) return null; + const to = state.selection.from; + if (from < 0 || from + 1 > state.doc.content.size || to <= from) return null; + + const $from = state.doc.resolve(from); + const $to = state.doc.resolve(to); + if (!$from.parent.isTextblock || $from.start() !== $to.start()) return null; + if ( + state.doc.textBetween(from, from + 1, OBJECT, OBJECT) !== active.trigger + ) { + return null; + } + + const query = state.doc.textBetween(from + 1, to, OBJECT, OBJECT); + if (query.includes(OBJECT)) return null; + + return { trigger: active.trigger, query, from, to }; +} + +function sameState( + a: SuggestionState | null, + b: SuggestionState | null +): boolean { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.trigger === b.trigger && + a.query === b.query && + a.from === b.from && + a.to === b.to + ); +} + +export function suggestionPlugin(options: SuggestionPluginOptions): Plugin { + // IME state is a view concern, not document state: a composition can span + // several transactions and must never open the menu part-way through. + let composing = false; + + return new Plugin({ + key: suggestionPluginKey, + + state: { + init: () => ({ active: null, dismissed: null }), + + apply(tr, previous, _old, next) { + const meta = tr.getMeta(suggestionPluginKey) as + | { type: 'dismiss' } + | undefined; + + let dismissed = previous.dismissed + ? { + ...previous.dismissed, + from: tr.mapping.map(previous.dismissed.from, -1) + } + : null; + + if (meta?.type === 'dismiss') { + return { + active: null, + dismissed: previous.active + ? { from: previous.active.from, query: previous.active.query } + : dismissed + }; + } + + if (previous.active) { + const carried = carry( + previous.active, + tr.mapping.map(previous.active.from, -1), + next + ); + if (carried) return { active: carried, dismissed }; + } + + if (composing) return { active: null, dismissed }; + + const detected = + next.selection instanceof TextSelection + ? detect(next.selection, options.getTriggers()) + : null; + + if ( + detected && + dismissed && + dismissed.from === detected.from && + dismissed.query === detected.query + ) { + return { active: null, dismissed }; + } + + if (detected) dismissed = null; + return { active: detected, dismissed }; + } + }, + + props: { + handleKeyDown(view, event) { + const active = suggestionPluginKey.getState(view.state)?.active; + if (!active) return false; + return options.onKeyDown(event, active); + }, + + handleDOMEvents: { + compositionstart: () => { + composing = true; + return false; + }, + compositionend: () => { + composing = false; + return false; + } + }, + + // The trigger and its query read as pending rather than as ordinary + // text while the menu is open. + decorations(state) { + const active = suggestionPluginKey.getState(state)?.active; + if (!active) return null; + return DecorationSet.create(state.doc, [ + Decoration.inline(active.from, active.to, { + class: styles.activeSuggestion + }) + ]); + } + }, + + view() { + let last: SuggestionState | null = null; + return { + update(view) { + const active = + suggestionPluginKey.getState(view.state)?.active ?? null; + if (sameState(active, last)) return; + last = active; + options.onStateChange(active); + }, + destroy() { + if (last) options.onStateChange(null); + } + }; + } + }); +} + +/** Closes the menu and leaves the typed text literal. */ +export function dismissSuggestion(view: EditorView): void { + view.dispatch( + view.state.tr.setMeta(suggestionPluginKey, { type: 'dismiss' }) + ); +} + +function isWhitespaceAt(state: EditorState, position: number): boolean { + if (position >= state.doc.content.size) return false; + return /\s/.test( + state.doc.textBetween(position, position + 1, OBJECT, OBJECT) + ); +} + +/** + * Replaces `range` with a chip followed by a single space, caret after it. + * With no range the chip lands at the caret. + */ +export function insertMention( + view: EditorView, + attrs: MentionAttrs, + range?: { from: number; to: number } +): void { + const { state } = view; + const target = range ?? { + from: state.selection.from, + to: state.selection.to + }; + + const tr = state.tr; + const nodes = [mentionType.create(attrs)]; + // Skip the trailing space when the caret already sits in front of one, so + // picking a mention mid-sentence does not leave a gap. + const spaced = isWhitespaceAt(state, target.to); + if (!spaced) nodes.push(editorSchema.text(' ')); + + tr.replaceWith(target.from, target.to, nodes); + const caret = Math.min(target.from + (spaced ? 1 : 2), tr.doc.content.size); + tr.setSelection(TextSelection.create(tr.doc, caret)); + tr.scrollIntoView(); + view.dispatch(tr); +} diff --git a/packages/raystack/components/editor/use-editor.ts b/packages/raystack/components/editor/use-editor.ts new file mode 100644 index 000000000..d01bda369 --- /dev/null +++ b/packages/raystack/components/editor/use-editor.ts @@ -0,0 +1,474 @@ +'use client'; + +import { baseKeymap } from 'prosemirror-commands'; +import { history, redo, undo } from 'prosemirror-history'; +import { keymap } from 'prosemirror-keymap'; +import { Slice } from 'prosemirror-model'; +import { + type Command, + EditorState, + Plugin, + Selection, + TextSelection +} from 'prosemirror-state'; +import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import styles from './editor.module.css'; +import { + deriveDocDetails, + docFromMarkup, + type EditorDocDetails, + flattenToInlineSlice, + inlineFragmentFromText, + isDocEmpty, + serializeText, + textFromFragment, + textLength +} from './markup'; +import { type MentionAttrs, mentionKey } from './mention'; +import { + MentionNodeView, + type MentionPortal, + type MentionPortalRegistry +} from './mention-node-view'; +import { hardBreakType, mentionType } from './schema'; +import { + dismissSuggestion, + insertMention as insertMentionAt, + type SuggestionState, + suggestionPlugin +} from './suggestion-plugin'; + +/** Marks transactions that came from outside the editor, so they are not echoed back. */ +const EXTERNAL = 'apsara-editor-external'; + +export interface UseEditorOptions { + /** Markup for the first document. Read once. */ + initialMarkup: string; + /** Placeholder shown while the document is empty. */ + placeholder?: string; + disabled?: boolean; + spellCheck?: boolean; + /** Cap on the derived plain text — a chip counts as its label. */ + maxLength?: number; + /** Trigger characters currently registered by a `Mentions` part. */ + getTriggers?: () => string[]; + /** Fires for every document change the user made. */ + onChange?: (details: EditorDocDetails) => void; + /** Enter with no active menu. */ + onSubmit?: () => void; + onSuggestionChange?: (state: SuggestionState | null) => void; + /** Return true to consume the key while a menu is open. */ + onSuggestionKeyDown?: ( + event: KeyboardEvent, + state: SuggestionState + ) => boolean; +} + +export interface EditorActions { + focus: () => void; + /** + * Replaces the document when `markup` differs from what the document already + * serializes to. The compare is what keeps a controlled `value` from + * resetting the caret on every keystroke. + */ + setMarkup: (markup: string) => void; + getDetails: () => EditorDocDetails; + insertMention: ( + attrs: MentionAttrs, + range?: { from: number; to: number } + ) => void; + /** Applies fresh labels from `resolveMentions` without touching history. */ + refreshMentionLabels: (labels: Map) => void; + dismissSuggestion: () => void; +} + +export interface UseEditorResult { + /** Attach to the element that becomes the editing host. */ + hostRef: (node: HTMLDivElement | null) => void; + /** + * Server and first-client markup for the host: the derived plain text, so a + * restored draft is readable before ProseMirror takes the subtree over. + */ + initialHtml: { __html: string }; + viewRef: React.RefObject; + mentionPortals: MentionPortal[]; + actions: EditorActions; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** Backspace/Delete take out the whole chip rather than selecting it first. */ +function deleteAdjacentMention(direction: -1 | 1): Command { + return (state, dispatch) => { + if (!state.selection.empty) return false; + const $pos = state.doc.resolve(state.selection.from); + const node = direction === -1 ? $pos.nodeBefore : $pos.nodeAfter; + if (!node || node.type !== mentionType) return false; + if (dispatch) { + const from = direction === -1 ? $pos.pos - node.nodeSize : $pos.pos; + dispatch(state.tr.delete(from, from + node.nodeSize)); + } + return true; + }; +} + +/** + * Arrow keys step over a chip in one press. ProseMirror's default for a + * selectable inline atom is to make it a NodeSelection first, which puts a + * selection ring on the chip on the way past it — a stop the user never asked + * for while moving the caret through a sentence. Clicking a chip still selects + * it, which is where the ring belongs. + */ +function moveOverMention(direction: -1 | 1): Command { + return (state, dispatch) => { + if (!state.selection.empty) return false; + const $pos = state.doc.resolve(state.selection.from); + const node = direction === -1 ? $pos.nodeBefore : $pos.nodeAfter; + if (!node || node.type !== mentionType) return false; + if (dispatch) { + const target = $pos.pos + direction * node.nodeSize; + dispatch( + state.tr + .setSelection(TextSelection.create(state.doc, target)) + .scrollIntoView() + ); + } + return true; + }; +} + +const insertHardBreak: Command = (state, dispatch) => { + if (dispatch) { + dispatch( + state.tr.replaceSelectionWith(hardBreakType.create()).scrollIntoView() + ); + } + return true; +}; + +export function useEditor(options: UseEditorOptions): UseEditorResult { + const optionsRef = useRef(options); + optionsRef.current = options; + + const viewRef = useRef(null); + const hostNodeRef = useRef(null); + const [mentionPortals, setMentionPortals] = useState([]); + + // Read once: after mount the document is the source of truth and incoming + // markup arrives through `setMarkup`. + const initialMarkupRef = useRef(options.initialMarkup); + const initialHtml = useMemo( + () => ({ + __html: escapeHtml(serializeText(docFromMarkup(initialMarkupRef.current))) + }), + [] + ); + + const registry = useMemo( + () => ({ + add: portal => setMentionPortals(current => [...current, portal]), + update: (id, attrs) => + setMentionPortals(current => + current.map(portal => + portal.id === id ? { ...portal, attrs } : portal + ) + ), + remove: id => + setMentionPortals(current => current.filter(portal => portal.id !== id)) + }), + [] + ); + + const hostRef = useCallback((node: HTMLDivElement | null) => { + hostNodeRef.current = node; + }, []); + + useLayoutEffect(() => { + const host = hostNodeRef.current; + if (!host) return; + + // ProseMirror owns this subtree from here on; the first-paint text is + // dropped so the view starts from a clean slate. + host.replaceChildren(); + + let everFocused = false; + let pointerFocus = false; + + const keysPlugin = new Plugin({ + props: { + handleKeyDown(view, event) { + if (event.key !== 'Enter') return false; + // A composing Enter confirms the composition; it never submits and it + // never reaches the document. + if (event.isComposing || event.keyCode === 229) return true; + if (event.shiftKey) return insertHardBreak(view.state, view.dispatch); + optionsRef.current.onSubmit?.(); + return true; + } + } + }); + + const placeholderPlugin = new Plugin({ + props: { + decorations(state) { + const text = optionsRef.current.placeholder; + if (!text || !isDocEmpty(state.doc)) return null; + return DecorationSet.create(state.doc, [ + Decoration.node(0, state.doc.content.size, { + class: styles.placeholder, + 'data-placeholder': text + }) + ]); + } + } + }); + + const clipboardPlugin = new Plugin({ + props: { + transformPasted: slice => flattenToInlineSlice(slice), + clipboardTextParser: text => + new Slice(inlineFragmentFromText(text), 0, 0), + clipboardTextSerializer: slice => textFromFragment(slice.content) + } + }); + + // A bare `focus()` on an editing host places no caret. When focus arrives + // from the frame rather than from a press inside the editor, drop the caret + // at the end — the way clicking past the end of a textarea's text behaves. + const focusPlugin = new Plugin({ + props: { + handleDOMEvents: { + mousedown: () => { + pointerFocus = true; + window.setTimeout(() => { + pointerFocus = false; + }, 0); + return false; + }, + touchstart: () => { + pointerFocus = true; + window.setTimeout(() => { + pointerFocus = false; + }, 0); + return false; + }, + focus: view => { + const first = !everFocused; + everFocused = true; + if (!first || pointerFocus) return false; + view.dispatch( + view.state.tr + .setSelection(Selection.atEnd(view.state.doc)) + .setMeta(EXTERNAL, true) + ); + return false; + } + } + } + }); + + // The composer is its own scroller, so the caret only ever has to be + // brought into *it*. ProseMirror's own scroll-into-view walks every + // scrollable ancestor up to the document, which nudges the page under the + // composer by a pixel or two on any edit that changes the caret's position. + const scrollPlugin = new Plugin({ + props: { + handleScrollToSelection(view) { + const host = view.dom as HTMLElement; + let coords: { top: number; bottom: number }; + try { + coords = view.coordsAtPos(view.state.selection.head); + } catch { + return true; + } + const box = host.getBoundingClientRect(); + if (coords.top < box.top) { + host.scrollTop -= box.top - coords.top; + } else if (coords.bottom > box.bottom) { + host.scrollTop += coords.bottom - box.bottom; + } + return true; + } + } + }); + + // A cap on the derived text, enforced as a transaction filter so paste and + // IME are covered and not just keystrokes. + const maxLengthPlugin = new Plugin({ + filterTransaction(transaction, current) { + const max = optionsRef.current.maxLength; + if (max == null || !transaction.docChanged) return true; + if (transaction.getMeta(EXTERNAL)) return true; + const next = textLength(transaction.doc); + return next <= max || next <= textLength(current.doc); + } + }); + + const initialDoc = docFromMarkup(initialMarkupRef.current); + + const state = EditorState.create({ + doc: initialDoc, + // A restored draft opens with the caret after it, the way reopening a + // half-written message in any composer behaves. + selection: Selection.atEnd(initialDoc), + plugins: [ + // First in the list, so an open menu wins ↑ ↓ Enter Tab Escape. + suggestionPlugin({ + getTriggers: () => optionsRef.current.getTriggers?.() ?? [], + onStateChange: next => optionsRef.current.onSuggestionChange?.(next), + onKeyDown: (event, suggestion) => + optionsRef.current.onSuggestionKeyDown?.(event, suggestion) ?? false + }), + keysPlugin, + keymap({ + Backspace: deleteAdjacentMention(-1), + Delete: deleteAdjacentMention(1), + ArrowLeft: moveOverMention(-1), + ArrowRight: moveOverMention(1), + 'Mod-z': undo, + 'Mod-y': redo, + 'Shift-Mod-z': redo + }), + history(), + keymap(baseKeymap), + maxLengthPlugin, + scrollPlugin, + placeholderPlugin, + clipboardPlugin, + focusPlugin + ] + }); + + let editorView: EditorView | null = null; + + const view = new EditorView( + { mount: host }, + { + state, + editable: () => !optionsRef.current.disabled, + nodeViews: { + mention: node => new MentionNodeView(node, registry) + }, + dispatchTransaction(transaction) { + if (!editorView) return; + const next = editorView.state.apply(transaction); + editorView.updateState(next); + if (!transaction.docChanged) return; + if (transaction.getMeta(EXTERNAL)) return; + optionsRef.current.onChange?.(deriveDocDetails(next.doc)); + } + } + ); + + editorView = view; + viewRef.current = view; + + return () => { + viewRef.current = null; + view.destroy(); + }; + }, [registry]); + + // `editable` is read through a prop function, so ProseMirror needs a nudge to + // re-read it when the composer is disabled or re-enabled. + useLayoutEffect(() => { + const view = viewRef.current; + if (!view) return; + view.setProps({ editable: () => !options.disabled }); + }, [options.disabled]); + + useLayoutEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dom.spellcheck = options.spellCheck ?? true; + }, [options.spellCheck]); + + // The placeholder is a decoration read from the options ref, so a changed + // string needs a state update to redraw it. An empty transaction changes no + // document, so it is never reported as a value change. + // biome-ignore lint/correctness/useExhaustiveDependencies: the dependency is the trigger, not a value the body reads + useLayoutEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch(view.state.tr.setMeta(EXTERNAL, true)); + }, [options.placeholder]); + + const actions = useMemo( + () => ({ + focus: () => viewRef.current?.focus(), + + setMarkup: markup => { + const view = viewRef.current; + if (!view) return; + if (deriveDocDetails(view.state.doc).markup === markup) return; + const replacement = docFromMarkup(markup); + const tr = view.state.tr; + tr.replace( + 0, + view.state.doc.content.size, + new Slice(replacement.content, 0, 0) + ); + tr.setSelection(Selection.atEnd(tr.doc)); + tr.setMeta(EXTERNAL, true); + tr.setMeta('addToHistory', false); + view.dispatch(tr); + }, + + getDetails: () => { + const view = viewRef.current; + if (!view) { + return deriveDocDetails(docFromMarkup(initialMarkupRef.current)); + } + return deriveDocDetails(view.state.doc); + }, + + insertMention: (attrs, range) => { + const view = viewRef.current; + if (!view) return; + if (!range && !view.hasFocus()) { + // Not focused: the chip belongs at the end of the draft. + const end = Selection.atEnd(view.state.doc).from; + insertMentionAt(view, attrs, { from: end, to: end }); + } else { + insertMentionAt(view, attrs, range); + } + view.focus(); + }, + + refreshMentionLabels: labels => { + const view = viewRef.current; + if (!view || labels.size === 0) return; + const tr = view.state.tr; + let changed = false; + view.state.doc.descendants((node, pos) => { + if (node.type !== mentionType) return; + const attrs = node.attrs as MentionAttrs; + const fresh = labels.get( + mentionKey(attrs.trigger, attrs.type, attrs.id) + ); + if (fresh && fresh !== attrs.label) { + tr.setNodeMarkup(pos, undefined, { ...attrs, label: fresh }); + changed = true; + } + }); + if (!changed) return; + tr.setMeta('addToHistory', false); + view.dispatch(tr); + }, + + dismissSuggestion: () => { + const view = viewRef.current; + if (view) dismissSuggestion(view); + } + }), + [] + ); + + return { hostRef, initialHtml, viewRef, mentionPortals, actions }; +} diff --git a/packages/raystack/components/prompt-input/__tests__/prompt-input-editor.test.tsx b/packages/raystack/components/prompt-input/__tests__/prompt-input-editor.test.tsx new file mode 100644 index 000000000..28c7dc366 --- /dev/null +++ b/packages/raystack/components/prompt-input/__tests__/prompt-input-editor.test.tsx @@ -0,0 +1,600 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { createRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { PromptInput } from '../prompt-input'; +import type { PromptInputMessage } from '../prompt-input-context'; +import type { PromptInputActions } from '../prompt-input-root'; + +/** + * The editor host. ProseMirror owns its subtree, so tests reach it the way a + * user does — through events on this element — rather than through React. + */ +function editorOf(container: HTMLElement): HTMLElement { + const node = container.querySelector('[role="textbox"]'); + if (!node) throw new Error('editor not found'); + return node as HTMLElement; +} + +/** + * jsdom has no contentEditable text input, so paste stands in for typing: it + * goes through the same `clipboardTextParser` and transaction path a keystroke + * would, and it is the one text-entry route ProseMirror exposes to synthetic + * events. + */ +function type(element: HTMLElement, text: string) { + fireEvent.paste(element, { + clipboardData: { + types: ['text/plain'], + files: [], + getData: (kind: string) => (kind === 'text/plain' ? text : '') + } + }); +} + +const flush = async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); +}; + +const Composer = (props: Partial[0]>) => ( + + + + + + +); + +describe('PromptInput.Editor', () => { + describe('Basic rendering', () => { + it('renders a multiline textbox', () => { + const { container } = render(); + const editor = editorOf(container); + + expect(editor).toHaveAttribute('aria-multiline', 'true'); + expect(editor).toHaveAttribute('contenteditable', 'true'); + }); + + it('shows the placeholder while empty, through a decoration', () => { + const { container } = render(); + + expect( + container.querySelector('[data-placeholder="Reply…"]') + ).not.toBeNull(); + }); + + it('drops the placeholder once there is content', () => { + const { container } = render(); + + expect(container.querySelector('[data-placeholder]')).toBeNull(); + }); + + it('keeps the placeholder behind whitespace only', () => { + const { container } = render(); + + expect(container.querySelector('[data-placeholder]')).not.toBeNull(); + }); + + it('throws when used outside the root', () => { + const spy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + expect(() => render()).toThrow( + /must be used within / + ); + spy.mockRestore(); + }); + }); + + describe('Markup parsing', () => { + it('renders a chip for a mention in defaultValue', () => { + const { container } = render( + + ); + + const chip = container.querySelector('[data-mention]'); + expect(chip).not.toBeNull(); + expect(chip).toHaveAttribute('data-mention-id', 'button'); + expect(chip).toHaveAttribute('data-mention-type', 'component'); + expect(chip).toHaveAttribute('aria-label', 'mention: Button'); + // The pill carries the label alone; the trigger lives in the serialized + // text and markup, where the mention boundary has to survive. + expect(chip?.textContent).toBe('Button'); + expect(editorOf(container).textContent).toBe('check Button tokens'); + }); + + it('reports the parsed mention through onValueChange when it changes', () => { + const onValueChange = vi.fn(); + const { container } = render( + + ); + + type(editorOf(container), 'ping'); + + const calls = onValueChange.mock.calls; + const [markup, details] = calls[calls.length - 1]; + expect(markup).toBe('@[Button](component:button) ping'); + expect(details.text).toBe('@Button ping'); + expect(details.mentions).toHaveLength(1); + expect(details.mentions[0]).toMatchObject({ + id: 'button', + type: 'component', + trigger: '@', + start: 0, + end: 7 + }); + }); + + it('leaves prose that merely looks like markup literal', () => { + const onValueChange = vi.fn(); + const { container } = render(); + + type(editorOf(container), 'ship @[x](y:z) today'); + + const calls = onValueChange.mock.calls; + const [, details] = calls[calls.length - 1]; + expect(details.mentions).toHaveLength(0); + expect(details.text).toBe('ship @[x](y:z) today'); + expect(container.querySelector('[data-mention]')).toBeNull(); + }); + }); + + describe('Chip behavior', () => { + const chipOf = (container: HTMLElement) => + container.querySelector('[data-mention]') as HTMLElement; + + it('steps the caret over a chip in one press, without selecting it', () => { + const { container } = render( + + ); + const editor = editorOf(container); + + // The caret opens after the chip; one press has to land it in front, + // rather than putting a selection ring on the chip on the way past. + fireEvent.keyDown(editor, { key: 'ArrowLeft' }); + + expect(chipOf(container)).not.toHaveAttribute('data-selected'); + type(editor, 'x'); + expect(editor.textContent).toBe('xButton'); + }); + + it('steps back over a chip on the way forward', () => { + const { container } = render( + + ); + const editor = editorOf(container); + + fireEvent.keyDown(editor, { key: 'ArrowLeft' }); + fireEvent.keyDown(editor, { key: 'ArrowRight' }); + + expect(chipOf(container)).not.toHaveAttribute('data-selected'); + type(editor, 'x'); + expect(editor.textContent).toBe('Buttonx'); + }); + + it('deletes the whole chip on one Backspace', () => { + const { container } = render( + + ); + const editor = editorOf(container); + + fireEvent.keyDown(editor, { key: 'Backspace' }); + + expect(container.querySelector('[data-mention]')).toBeNull(); + expect(editor.textContent).toBe(''); + }); + + it('deletes the whole chip on one Delete from in front of it', () => { + const { container } = render( + + ); + const editor = editorOf(container); + + fireEvent.keyDown(editor, { key: 'ArrowLeft' }); + fireEvent.keyDown(editor, { key: 'Delete' }); + + expect(container.querySelector('[data-mention]')).toBeNull(); + expect(editor.textContent).toBe(''); + }); + }); + + describe('Value model', () => { + it('reports markup that round-trips back into value', () => { + const onValueChange = vi.fn(); + const { container } = render(); + + type(editorOf(container), 'hello'); + + expect(onValueChange).toHaveBeenLastCalledWith('hello', { + text: 'hello', + mentions: [] + }); + }); + + it('reverts a controlled value the parent did not accept', () => { + const onValueChange = vi.fn(); + const { container } = render( + + ); + + type(editorOf(container), '!'); + + expect(onValueChange).toHaveBeenCalledWith('controlled!', { + text: 'controlled!', + mentions: [] + }); + expect(editorOf(container).textContent).toBe('controlled'); + }); + + it('re-parses a controlled value the parent did change', async () => { + const Controlled = () => { + const [value, setValue] = useState('plain'); + return ( + <> + + + + ); + }; + const { container } = render(); + + expect(container.querySelector('[data-mention]')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'swap' })); + await flush(); + + expect(container.querySelector('[data-mention]')).not.toBeNull(); + expect(editorOf(container).textContent).toBe('see Button'); + }); + + it('leaves the caret alone when the value it is given matches', () => { + const onValueChange = vi.fn(); + const { container } = render( + + ); + + type(editorOf(container), 'd'); + const callsAfterFirst = onValueChange.mock.calls.length; + type(editorOf(container), 'e'); + + // A serialize-compare no-op would show up as extra change reports. + expect(onValueChange.mock.calls.length).toBe(callsAfterFirst + 1); + expect(editorOf(container).textContent).toBe('abcde'); + }); + + it('clears when the form is reset from onSubmit', () => { + const onSubmit = vi.fn( + ( + _message: PromptInputMessage, + event: { currentTarget: HTMLFormElement } + ) => event.currentTarget.reset() + ); + const { container } = render(); + const editor = editorOf(container); + + type(editor, 'clear me'); + fireEvent.keyDown(editor, { key: 'Enter' }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(editor.textContent).toBe(''); + expect(container.querySelector('[data-placeholder]')).not.toBeNull(); + }); + }); + + describe('Emptiness and submission', () => { + it('submits the trimmed message on Enter', () => { + const onSubmit = vi.fn(); + const { container } = render(); + const editor = editorOf(container); + + type(editor, ' hello world '); + fireEvent.keyDown(editor, { key: 'Enter' }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toEqual({ + text: 'hello world', + markup: 'hello world', + mentions: [] + }); + }); + + it('sends a message that is nothing but a chip', () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + + expect( + screen.getByRole('button', { name: 'Send message' }) + ).toBeEnabled(); + + fireEvent.keyDown(editorOf(container), { key: 'Enter' }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const message: PromptInputMessage = onSubmit.mock.calls[0][0]; + expect(message.text).toBe('@Button'); + expect(message.markup).toBe('@[Button](component:button)'); + expect(message.mentions).toHaveLength(1); + expect(message.mentions[0]).toMatchObject({ + id: 'button', + start: 0, + end: 7 + }); + }); + + it('does not submit whitespace, and marks the frame empty', () => { + const onSubmit = vi.fn(); + const { container } = render(); + const editor = editorOf(container); + + type(editor, ' '); + fireEvent.keyDown(editor, { key: 'Enter' }); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(container.querySelector('form')).toHaveAttribute('data-empty'); + expect( + screen.getByRole('button', { name: 'Send message' }) + ).toBeDisabled(); + }); + + it('does not mark the frame empty for a chip-only draft', () => { + const { container } = render( + + ); + + expect(container.querySelector('form')).not.toHaveAttribute('data-empty'); + }); + + it('inserts a line break on Shift+Enter instead of submitting', () => { + const onSubmit = vi.fn(); + const onValueChange = vi.fn(); + const { container } = render( + + ); + const editor = editorOf(container); + + type(editor, 'line one'); + fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true }); + type(editor, 'line two'); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onValueChange).toHaveBeenLastCalledWith('line one\nline two', { + text: 'line one\nline two', + mentions: [] + }); + }); + + it('does not submit while composing with an IME', () => { + const onSubmit = vi.fn(); + const { container } = render(); + const editor = editorOf(container); + + type(editor, 'かな'); + fireEvent.keyDown(editor, { key: 'Enter', isComposing: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit while a response is in flight', () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + const editor = editorOf(container); + + type(editor, 'queued'); + fireEvent.keyDown(editor, { key: 'Enter' }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); + + describe('maxLength', () => { + it('rejects text past the cap, including pasted text', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + const editor = editorOf(container); + + type(editor, 'abcde'); + type(editor, 'fgh'); + + expect(editor.textContent).toBe('abcde'); + expect(onValueChange).toHaveBeenLastCalledWith('abcde', { + text: 'abcde', + mentions: [] + }); + }); + + it('counts a chip as its label', () => { + const { container } = render( + + + + ); + const editor = editorOf(container); + + // The derived text is "@Button" — exactly 7 characters counting the + // trigger the chip does not render — so nothing more fits. + type(editor, '!'); + expect(editor.textContent).toBe('Button'); + }); + + it('never rejects the initial value', () => { + const { container } = render( + + + + ); + + expect(editorOf(container).textContent).toBe('far longer than the cap'); + }); + + it('allows an edit that shrinks an already over-long document', () => { + // Over the cap from the start — a restored draft, or a lowered cap. The + // filter has to let it shrink or the composer would be frozen. + const { container } = render( + + + + ); + const editor = editorOf(container); + expect(editor.textContent).toBe('abcdefghButton'); + + fireEvent.keyDown(editor, { key: 'Backspace' }); + + expect(editor.textContent).toBe('abcdefgh'); + }); + }); + + describe('Disabled', () => { + it('makes the host non-editable and marks it', () => { + const { container } = render(); + const editor = editorOf(container); + + expect(editor).toHaveAttribute('contenteditable', 'false'); + expect(editor).toHaveAttribute('data-disabled'); + expect(editor).toHaveAttribute('aria-disabled', 'true'); + }); + + it('re-enables when the root does', async () => { + const Toggle = () => { + const [disabled, setDisabled] = useState(true); + return ( + <> + + + + ); + }; + const { container } = render(); + + expect(editorOf(container)).toHaveAttribute('contenteditable', 'false'); + + fireEvent.click(screen.getByRole('button', { name: 'enable' })); + await flush(); + + expect(editorOf(container)).toHaveAttribute('contenteditable', 'true'); + }); + }); + + describe('Click to focus', () => { + it('focuses the editor when the frame is pressed', () => { + const { container } = render(); + const form = container.querySelector('form') as HTMLFormElement; + + fireEvent.mouseDown(form); + + expect(editorOf(container)).toHaveFocus(); + }); + + it('leaves a press inside the editor to ProseMirror', () => { + const { container } = render(); + + const notCancelled = fireEvent.mouseDown(editorOf(container)); + + expect(notCancelled).toBe(true); + }); + }); + + describe('actionsRef', () => { + it('exposes focus, clear, getValue and insertMention', () => { + const actionsRef = createRef(); + const { container } = render( + + + + + ); + const editor = editorOf(container); + + expect(actionsRef.current).not.toBeNull(); + + act(() => actionsRef.current?.focus()); + expect(editor).toHaveFocus(); + + expect(actionsRef.current?.getValue()).toEqual({ + markup: 'draft ', + text: 'draft ', + mentions: [] + }); + + act(() => + actionsRef.current?.insertMention({ + id: 'button', + label: 'Button', + type: 'component', + data: { slug: 'zenith' } + }) + ); + + expect( + container.querySelector('[data-mention-id="button"]') + ).not.toBeNull(); + const value = actionsRef.current?.getValue(); + expect(value?.markup).toBe('draft @[Button](component:button) '); + expect(value?.mentions[0].data).toEqual({ slug: 'zenith' }); + + act(() => actionsRef.current?.clear()); + expect(editor.textContent).toBe(''); + }); + + it('warns when insertMention is used without an Editor', () => { + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const actionsRef = createRef(); + render( + + + + ); + + act(() => actionsRef.current?.insertMention({ id: 'a', label: 'A' })); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('insertMention requires') + ); + warn.mockRestore(); + }); + }); + + describe('Mutually exclusive input parts', () => { + it('warns in development and keeps the first that mounted', () => { + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const { container } = render( + + + + + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('mutually exclusive') + ); + + fireEvent.mouseDown(container.querySelector('form') as HTMLFormElement); + expect(screen.getByPlaceholderText('Reply…')).toHaveFocus(); + warn.mockRestore(); + }); + }); +}); diff --git a/packages/raystack/components/prompt-input/__tests__/prompt-input-mentions.test.tsx b/packages/raystack/components/prompt-input/__tests__/prompt-input-mentions.test.tsx new file mode 100644 index 000000000..b20960033 --- /dev/null +++ b/packages/raystack/components/prompt-input/__tests__/prompt-input-mentions.test.tsx @@ -0,0 +1,836 @@ +import { + act, + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PromptInput } from '../prompt-input'; +import type { PromptInputMessage } from '../prompt-input-context'; +import type { PromptInputMentionItem } from '../prompt-input-mention-registry'; + +function editorOf(container: HTMLElement): HTMLElement { + const node = container.querySelector('[role="textbox"]'); + if (!node) throw new Error('editor not found'); + return node as HTMLElement; +} + +/** Stands in for typing; see the note in prompt-input-editor.test.tsx. */ +function type(element: HTMLElement, text: string) { + fireEvent.paste(element, { + clipboardData: { + types: ['text/plain'], + files: [], + getData: (kind: string) => (kind === 'text/plain' ? text : '') + } + }); +} + +const flush = async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); +}; + +const ITEMS: PromptInputMentionItem[] = [ + { id: 'button', label: 'Button', type: 'component', group: 'Components' }, + { + id: 'data-table', + label: 'DataTable', + type: 'component', + group: 'Components' + }, + { + id: 'dialog', + label: 'Dialog', + type: 'component', + group: 'Components', + disabled: true + }, + { id: 'u1', label: 'Maya Chen', type: 'user', group: 'Users' }, + { id: 'u2', label: 'Dana Whitfield', type: 'user', group: 'Users' } +]; + +const Composer = ({ + mentions, + ...props +}: Partial[0]> & { + mentions?: Partial[0]>; +}) => ( + + + + + + + +); + +describe('PromptInput.Mentions', () => { + describe('Opening', () => { + it('opens on a trigger at the start of the document', async () => { + const { container } = render(); + + type(editorOf(container), '@'); + await flush(); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(ITEMS.length); + }); + + it('opens on a trigger after whitespace', async () => { + const { container } = render(); + + type(editorOf(container), 'ping @'); + await flush(); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('stays closed for a trigger inside a word', async () => { + const { container } = render(); + + type(editorOf(container), 'name@'); + await flush(); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('stays closed while the composer is disabled', async () => { + const { container } = render(); + + fireEvent.keyDown(editorOf(container), { key: 'ArrowDown' }); + await flush(); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('does not render a menu at all without a Mentions part', async () => { + const { container } = render( + + + + ); + + type(editorOf(container), '@'); + await flush(); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(editorOf(container)).not.toHaveAttribute('aria-autocomplete'); + }); + + it('marks the trigger and its query as pending while open', async () => { + const { container } = render(); + + type(editorOf(container), '@may'); + await flush(); + + // The decoration wraps the active range in its own span. + const decorated = container.querySelector('[role="textbox"] span'); + expect(decorated?.textContent).toBe('@may'); + }); + + it('reports open state through onOpenChange', async () => { + const onOpenChange = vi.fn(); + const { container } = render(); + + type(editorOf(container), '@'); + await flush(); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + + fireEvent.keyDown(editorOf(container), { key: 'Escape' }); + await flush(); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + }); + + describe('Filtering and grouping', () => { + it('renders groups in first-appearance order, ungrouped first', async () => { + const { container } = render( + + ); + + type(editorOf(container), '@'); + await flush(); + + const options = screen.getAllByRole('option').map(o => o.textContent); + expect(options[0]).toBe('Loose'); + expect(options).toEqual([ + 'Loose', + 'Button', + 'DataTable', + 'Dialog', + 'Maya Chen', + 'Dana Whitfield' + ]); + }); + + it('filters on the label and drops groups that empty out', async () => { + const { container } = render(); + + type(editorOf(container), '@maya'); + await flush(); + + const options = screen.getAllByRole('option').map(o => o.textContent); + expect(options).toEqual(['Maya Chen']); + expect(screen.queryByText('Components')).not.toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + }); + + // Only a single-word query can *open* the menu — the backward scan stops at + // whitespace — so a multi-word query is carried forward by the active state, + // exactly as it is when a user types the space. + it('keeps a multi-word query filterable', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@Maya'); + await flush(); + type(editor, ' Ch'); + await flush(); + + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ + 'Maya Chen' + ]); + }); + + it('does not open for a multi-word query pasted in one go', async () => { + const { container } = render(); + + type(editorOf(container), '@Maya Chen'); + await flush(); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('shows the empty state for a single-word query with no matches', async () => { + const { container } = render( + + ); + + type(editorOf(container), '@zzzz'); + await flush(); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getByText('Nothing here')).toBeInTheDocument(); + }); + + it('cancels once a query containing a space stops matching', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@Maya'); + await flush(); + type(editor, ' Ch'); + await flush(); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + type(editor, ' nope'); + await flush(); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + // The text stays exactly as it was typed. + expect(editor.textContent).toBe('@Maya Ch nope'); + }); + + it('keeps the menu open on a single-word query with no matches', async () => { + const { container } = render(); + + type(editorOf(container), '@zzzz'); + await flush(); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('auto-highlights the first enabled row', async () => { + const { container } = render( + + ); + + type(editorOf(container), '@'); + await flush(); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('Key routing', () => { + it('moves the highlight with the arrows and skips disabled rows', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@'); + await flush(); + + const selected = () => + screen + .getAllByRole('option') + .findIndex(o => o.getAttribute('aria-selected') === 'true'); + + expect(selected()).toBe(0); + + fireEvent.keyDown(editor, { key: 'ArrowDown' }); + await flush(); + expect(selected()).toBe(1); + + // Index 2 is disabled, so it is skipped. + fireEvent.keyDown(editor, { key: 'ArrowDown' }); + await flush(); + expect(selected()).toBe(3); + + fireEvent.keyDown(editor, { key: 'ArrowUp' }); + await flush(); + expect(selected()).toBe(1); + }); + + it('wraps around at the ends', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@'); + await flush(); + fireEvent.keyDown(editor, { key: 'ArrowUp' }); + await flush(); + + const options = screen.getAllByRole('option'); + expect(options[options.length - 1]).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('tracks the highlight with aria-activedescendant', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@'); + await flush(); + + const active = editor.getAttribute('aria-activedescendant'); + expect(active).toBeTruthy(); + expect(screen.getAllByRole('option')[0]).toHaveAttribute('id', active); + expect(editor).toHaveAttribute('aria-expanded', 'true'); + expect(editor).toHaveAttribute( + 'aria-controls', + screen.getByRole('listbox').id + ); + }); + + it('inserts on Enter and does not submit', async () => { + const onSubmit = vi.fn(); + const { container } = render(); + const editor = editorOf(container); + + type(editor, 'ship @maya'); + await flush(); + fireEvent.keyDown(editor, { key: 'Enter' }); + await flush(); + + expect(onSubmit).not.toHaveBeenCalled(); + // The chip renders its label alone; the trigger stays in the payload. + expect(editor.textContent).toBe('ship Maya Chen '); + expect(container.querySelector('[data-mention-id="u1"]')).not.toBeNull(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('inserts on Tab', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@maya'); + await flush(); + fireEvent.keyDown(editor, { key: 'Tab' }); + await flush(); + + expect(editor.textContent).toBe('Maya Chen '); + }); + + it('closes on Escape, keeps the text literal, and reopens on a change', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@may'); + await flush(); + fireEvent.keyDown(editor, { key: 'Escape' }); + await flush(); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(editor.textContent).toBe('@may'); + + type(editor, 'a'); + await flush(); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('does not let Escape escape while the menu is open', async () => { + const onKeyDownOutside = vi.fn(); + const { container } = render( +
+ +
+ ); + const editor = editorOf(container); + + type(editor, '@'); + await flush(); + fireEvent.keyDown(editor, { key: 'Escape' }); + + expect(onKeyDownOutside).not.toHaveBeenCalled(); + }); + + it('lets Escape bubble once the menu is closed', async () => { + const onKeyDownOutside = vi.fn(); + const { container } = render( +
+ +
+ ); + + fireEvent.keyDown(editorOf(container), { key: 'Escape' }); + + expect(onKeyDownOutside).toHaveBeenCalled(); + }); + + it('submits on Enter when the menu is open on an empty result set', async () => { + const onSubmit = vi.fn(); + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@zzzz'); + await flush(); + expect(screen.getByText('No results')).toBeInTheDocument(); + + fireEvent.keyDown(editor, { key: 'Enter' }); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].text).toBe('@zzzz'); + }); + }); + + describe('Pointer selection', () => { + it('inserts on click without taking focus off the editor', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@maya'); + await flush(); + + const option = screen.getByRole('option', { name: 'Maya Chen' }); + const notCancelled = fireEvent.pointerDown(option); + expect(notCancelled).toBe(false); + + fireEvent.click(option); + await flush(); + + expect(editor.textContent).toBe('Maya Chen '); + }); + + it('dismisses on a press outside the composer, leaving the text literal', async () => { + const { container } = render( + <> + + + + ); + const editor = editorOf(container); + + type(editor, '@maya'); + await flush(); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'elsewhere' })); + await flush(); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(editor.textContent).toBe('@maya'); + }); + + it('ignores a click on a disabled row', async () => { + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@Dial'); + await flush(); + + fireEvent.click(screen.getByRole('option', { name: 'Dialog' })); + await flush(); + + expect(editor.textContent).toBe('@Dial'); + }); + }); + + describe('Submit payload', () => { + it('carries the picked item data and text offsets', async () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + const editor = editorOf(container); + + type(editor, 'ship @data'); + await flush(); + fireEvent.keyDown(editor, { key: 'Enter' }); + await flush(); + fireEvent.keyDown(editor, { key: 'Enter' }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const message: PromptInputMessage = onSubmit.mock.calls[0][0]; + expect(message.markup).toBe('ship @[DataTable](component:data-table)'); + expect(message.text).toBe('ship @DataTable'); + expect(message.mentions).toEqual([ + { + id: 'data-table', + label: 'DataTable', + type: 'component', + trigger: '@', + data: { status: 'stable' }, + start: 5, + end: 15 + } + ]); + expect(message.text.slice(5, 15)).toBe('@DataTable'); + }); + }); + + describe('Async search', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('debounces, shows skeleton rows, then the results', async () => { + const onSearch = vi.fn( + async (_query: string): Promise => [ + { id: 'data-table', label: 'DataTable', type: 'component' } + ] + ); + const { container } = render( + + ); + const editor = editorOf(container); + + type(editor, '@d'); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(onSearch).not.toHaveBeenCalled(); + expect( + screen.getByRole('listbox').querySelectorAll('[aria-hidden="true"]') + .length + ).toBeGreaterThanOrEqual(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(onSearch).toHaveBeenCalledTimes(1); + expect(onSearch.mock.calls[0][0]).toBe('d'); + await waitFor(() => + expect(screen.getByRole('option', { name: 'DataTable' })).toBeVisible() + ); + }); + + it('makes one request for a query typed in two bursts', async () => { + const onSearch = vi.fn( + async (_query: string): Promise => [] + ); + const { container } = render( + + ); + const editor = editorOf(container); + + type(editor, '@da'); + await act(async () => { + await vi.advanceTimersByTimeAsync(80); + }); + type(editor, 'tatable'); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(onSearch).toHaveBeenCalledTimes(1); + expect(onSearch.mock.calls[0][0]).toBe('datatable'); + }); + + it('aborts a superseded request and discards its result', async () => { + const aborted: boolean[] = []; + let resolveFirst: ((items: PromptInputMentionItem[]) => void) | null = + null; + + const onSearch = vi.fn( + (query: string, { signal }: { signal: AbortSignal }) => { + if (query === 'da') { + signal.addEventListener('abort', () => aborted.push(true)); + return new Promise(resolve => { + resolveFirst = resolve; + }); + } + return Promise.resolve([ + { id: 'data-table', label: 'DataTable', type: 'component' } + ]); + } + ); + + const { container } = render( + + ); + const editor = editorOf(container); + + type(editor, '@da'); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + type(editor, 't'); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(aborted).toEqual([true]); + + // The stale resolution lands late and must be ignored. + await act(async () => { + resolveFirst?.([{ id: 'stale', label: 'Stale', type: 'component' }]); + await Promise.resolve(); + }); + + expect(screen.queryByText('Stale')).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole('option', { name: 'DataTable' })).toBeVisible() + ); + }); + + it('falls back to the empty state when the search rejects', async () => { + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const onSearch = vi.fn( + async (_query: string): Promise => { + throw new Error('offline'); + } + ); + const { container } = render( + + ); + + type(editorOf(container), '@d'); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + await waitFor(() => + expect(screen.getByText('No results')).toBeInTheDocument() + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('onSearch rejected'), + expect.any(Error) + ); + warn.mockRestore(); + }); + + // A re-render rather than a click: a press outside the composer is an + // outside press, which legitimately dismisses the menu. + it('does not restart an in-flight search when unrelated props change', async () => { + const onSearch = vi.fn( + async (_query: string): Promise => [] + ); + const { container, rerender } = render( + + ); + + type(editorOf(container), '@d'); + await act(async () => { + await vi.advanceTimersByTimeAsync(80); + }); + expect(onSearch).not.toHaveBeenCalled(); + + // New inline `mentions` object, same data — the config must not churn. + rerender( + + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + + // Still the original 150 ms window, not a restarted one. + expect(onSearch).toHaveBeenCalledTimes(1); + expect(onSearch.mock.calls[0][0]).toBe('d'); + }); + }); + + describe('Hydration through resolveMentions', () => { + it('fills in the icon and a fresh label for a parsed chip', async () => { + const resolveMentions = vi.fn(async () => [ + { + id: 'data-table', + label: 'DataTable v2', + type: 'component', + icon: + } + ]); + const { container } = render( + + ); + + // Label-only until it resolves — never a skeleton, never an error state. + expect(container.querySelector('[data-mention]')?.textContent).toBe( + 'DataTable' + ); + + await waitFor(() => expect(screen.getByTestId('icon')).toBeVisible()); + + expect(resolveMentions).toHaveBeenCalledWith([ + { type: 'component', id: 'data-table', label: 'DataTable' } + ]); + expect( + container.querySelector('[data-mention]')?.getAttribute('aria-label') + ).toBe('mention: DataTable v2'); + }); + + it('batches every unresolved chip into one call', async () => { + const resolveMentions = vi.fn( + async ( + _refs: Array<{ type: string; id: string; label: string }> + ): Promise => [] + ); + render( + + ); + + await waitFor(() => expect(resolveMentions).toHaveBeenCalledTimes(1)); + expect(resolveMentions.mock.calls[0][0]).toEqual([ + { type: 'component', id: 'a', label: 'A' }, + { type: 'user', id: 'b', label: 'B' } + ]); + }); + + it('leaves the chip label-only when resolution rejects', async () => { + const resolveMentions = vi.fn(async () => { + throw new Error('nope'); + }); + const { container } = render( + + ); + + await waitFor(() => expect(resolveMentions).toHaveBeenCalled()); + await flush(); + + const chip = container.querySelector('[data-mention]'); + expect(chip).not.toBeNull(); + expect(chip?.textContent).toBe('DataTable'); + }); + + it('does not ask twice for the same reference', async () => { + const resolveMentions = vi.fn( + async ( + _refs: Array<{ type: string; id: string; label: string }> + ): Promise => [] + ); + const { container } = render( + + ); + + await waitFor(() => expect(resolveMentions).toHaveBeenCalledTimes(1)); + + type(editorOf(container), 'more text'); + await flush(); + + expect(resolveMentions).toHaveBeenCalledTimes(1); + }); + + it('skips resolution for a chip already picked from the menu', async () => { + const resolveMentions = vi.fn( + async ( + _refs: Array<{ type: string; id: string; label: string }> + ): Promise => [] + ); + const { container } = render(); + const editor = editorOf(container); + + type(editor, '@maya'); + await flush(); + fireEvent.keyDown(editor, { key: 'Enter' }); + await flush(); + + expect(resolveMentions).not.toHaveBeenCalled(); + }); + }); + + describe('Requires an Editor', () => { + it('does nothing next to a Textarea and warns in development', async () => { + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + render( + + + + + ); + + await flush(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('requires ') + ); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + warn.mockRestore(); + }); + + it('stays quiet when an Editor is present', async () => { + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + render(); + + await flush(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + }); +}); diff --git a/packages/raystack/components/prompt-input/__tests__/prompt-input-parity.test.tsx b/packages/raystack/components/prompt-input/__tests__/prompt-input-parity.test.tsx new file mode 100644 index 000000000..ff1407248 --- /dev/null +++ b/packages/raystack/components/prompt-input/__tests__/prompt-input-parity.test.tsx @@ -0,0 +1,284 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { PromptInput } from '../prompt-input'; +import type { PromptInputMessage } from '../prompt-input-context'; + +/** + * Keeping `PromptInput.Textarea` is what buys plain composers a composer with no + * editor engine in it, and the price is two implementations of one contract. + * This is the guard against them drifting: every case below runs against both + * parts from one table. + */ +interface Substrate { + name: string; + render: (props: Partial[0]>) => ReactElement; + /** The element that receives keys — a textarea or an editing host. */ + input: (container: HTMLElement) => HTMLElement; + /** Enters text the way that substrate accepts it in jsdom. */ + type: (element: HTMLElement, text: string) => void; + /** The visible value. */ + read: (element: HTMLElement) => string; + /** + * Whether the placeholder is showing. A textarea has the native attribute; the + * editor renders a decoration, because a ProseMirror-empty paragraph still + * holds a trailing `
` and would defeat CSS `:empty`. + */ + placeholderShown: (container: HTMLElement) => boolean; +} + +const Frame = ({ + children, + ...props +}: Partial[0]> & { children: ReactElement }) => ( + + {children} + + + + +); + +const substrates: Substrate[] = [ + { + name: 'Textarea', + render: props => ( + + + + ), + input: () => screen.getByPlaceholderText('Reply…'), + type: (element, text) => { + const field = element as HTMLTextAreaElement; + fireEvent.change(field, { target: { value: field.value + text } }); + }, + read: element => (element as HTMLTextAreaElement).value, + // A browser paints the native placeholder only while the value is empty. + placeholderShown: container => { + const field = container.querySelector( + '[placeholder="Reply…"]' + ); + return field !== null && field.value === ''; + } + }, + { + name: 'Editor', + render: props => ( + + + + ), + input: container => { + const node = container.querySelector('[role="textbox"]'); + if (!node) throw new Error('editor not found'); + return node as HTMLElement; + }, + // jsdom has no contentEditable text input; paste is the one text-entry route + // ProseMirror exposes to synthetic events, and it lands in the same place. + type: (element, text) => { + fireEvent.paste(element, { + clipboardData: { + types: ['text/plain'], + files: [], + getData: (kind: string) => (kind === 'text/plain' ? text : '') + } + }); + }, + read: element => element.textContent ?? '', + placeholderShown: container => + container.querySelector('[data-placeholder="Reply…"]') !== null + } +]; + +describe.each(substrates)('PromptInput shared contract — $name', substrate => { + const setup = (props: Partial[0]> = {}) => { + const result = render(substrate.render(props)); + return { ...result, input: substrate.input(result.container) }; + }; + + it('shows the placeholder while empty and drops it once there is content', () => { + const { container, input } = setup(); + + expect(substrate.placeholderShown(container)).toBe(true); + + substrate.type(input, 'x'); + expect(substrate.placeholderShown(container)).toBe(false); + }); + + it('reports value changes in the same shape', () => { + const onValueChange = vi.fn(); + const { input } = setup({ onValueChange }); + + substrate.type(input, 'hello'); + + expect(onValueChange).toHaveBeenLastCalledWith('hello', { + text: 'hello', + mentions: [] + }); + }); + + it('submits the trimmed message on Enter', () => { + const onSubmit = vi.fn(); + const { input } = setup({ onSubmit }); + + substrate.type(input, ' hello world '); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const message: PromptInputMessage = onSubmit.mock.calls[0][0]; + expect(message).toEqual({ + text: 'hello world', + markup: 'hello world', + mentions: [] + }); + }); + + it('does not submit on Shift+Enter', () => { + const onSubmit = vi.fn(); + const { input } = setup({ onSubmit }); + + substrate.type(input, 'line one'); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit while composing with an IME', () => { + const onSubmit = vi.fn(); + const { input } = setup({ onSubmit }); + + substrate.type(input, 'かな'); + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit an empty or whitespace-only value', () => { + const onSubmit = vi.fn(); + const { input } = setup({ onSubmit }); + + fireEvent.keyDown(input, { key: 'Enter' }); + substrate.type(input, ' '); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('marks the frame empty until there is content', () => { + const { container, input } = setup(); + const form = container.querySelector('form') as HTMLFormElement; + + expect(form).toHaveAttribute('data-empty'); + + substrate.type(input, 'x'); + expect(form).not.toHaveAttribute('data-empty'); + }); + + it('disables the submit button while empty', () => { + const { input } = setup(); + const submit = screen.getByRole('button', { name: 'Send message' }); + + expect(submit).toBeDisabled(); + + substrate.type(input, 'x'); + expect(submit).toBeEnabled(); + }); + + it('submits through the submit button', () => { + const onSubmit = vi.fn(); + const { input } = setup({ onSubmit }); + + substrate.type(input, 'hi'); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].text).toBe('hi'); + }); + + it('does not submit on Enter while streaming', () => { + const onSubmit = vi.fn(); + const { input } = setup({ status: 'streaming', onSubmit }); + + substrate.type(input, 'queued'); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('routes the stop control to onStop', () => { + const onStop = vi.fn(); + setup({ status: 'streaming', onStop }); + + const stop = screen.getByRole('button', { name: 'Stop response' }); + expect(stop).toHaveAttribute('type', 'button'); + + fireEvent.click(stop); + expect(onStop).toHaveBeenCalledTimes(1); + }); + + it('clears on form.reset() from onSubmit', () => { + const onSubmit = vi.fn( + ( + _message: PromptInputMessage, + event: { currentTarget: HTMLFormElement } + ) => event.currentTarget.reset() + ); + const { input } = setup({ onSubmit }); + + substrate.type(input, 'clear me'); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(substrate.read(input)).toBe(''); + }); + + it('renders defaultValue', () => { + const { input } = setup({ defaultValue: 'restored draft' }); + expect(substrate.read(input)).toBe('restored draft'); + }); + + it('keeps a controlled value the parent did not accept', () => { + const { input } = setup({ value: 'controlled' }); + + substrate.type(input, '!'); + + expect(substrate.read(input)).toBe('controlled'); + }); + + it('focuses the input when the frame is pressed', () => { + const { container, input } = setup(); + const form = container.querySelector('form') as HTMLFormElement; + + const notCancelled = fireEvent.mouseDown(form); + + expect(notCancelled).toBe(false); + expect(input).toHaveFocus(); + }); + + it('leaves a press on the input itself alone', () => { + const { input } = setup({ defaultValue: 'hello' }); + + expect(fireEvent.mouseDown(input)).toBe(true); + }); + + it('does nothing on a frame press while disabled', () => { + const { container, input } = setup({ disabled: true }); + const form = container.querySelector('form') as HTMLFormElement; + + fireEvent.mouseDown(form); + + expect(input).not.toHaveFocus(); + }); + + it('keeps a literal markup-shaped string literal', () => { + const onSubmit = vi.fn(); + const { input } = setup({ onSubmit }); + + substrate.type(input, 'ship @[x](y:z) today'); + fireEvent.keyDown(input, { key: 'Enter' }); + + const message: PromptInputMessage = onSubmit.mock.calls[0][0]; + expect(message.text).toBe('ship @[x](y:z) today'); + expect(message.mentions).toHaveLength(0); + }); +}); diff --git a/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx b/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx index 7d57eb0d5..c0f41c43e 100644 --- a/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx +++ b/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx @@ -63,7 +63,10 @@ describe('PromptInput', () => { await user.type(textarea, 'hello'); expect(textarea).toHaveValue('hello'); - expect(onValueChange).toHaveBeenLastCalledWith('hello'); + expect(onValueChange).toHaveBeenLastCalledWith('hello', { + text: 'hello', + mentions: [] + }); }); it('submits the trimmed value on Enter', async () => { @@ -76,7 +79,11 @@ describe('PromptInput', () => { await user.keyboard('{Enter}'); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toBe('hello world'); + expect(onSubmit.mock.calls[0][0]).toEqual({ + text: 'hello world', + markup: 'hello world', + mentions: [] + }); }); it('inserts a newline on Shift+Enter instead of submitting', async () => { @@ -125,7 +132,7 @@ describe('PromptInput', () => { await user.click(screen.getByRole('button', { name: 'Send message' })); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toBe('hi'); + expect(onSubmit.mock.calls[0][0].text).toBe('hi'); }); it('supports a controlled value', async () => { @@ -140,7 +147,10 @@ describe('PromptInput', () => { await user.type(textarea, '!'); // Parent did not update the prop, so the value stays. expect(textarea).toHaveValue('controlled'); - expect(onValueChange).toHaveBeenCalledWith('controlled!'); + expect(onValueChange).toHaveBeenCalledWith('controlled!', { + text: 'controlled!', + mentions: [] + }); }); it('clears the value when the form is reset from onSubmit', async () => { diff --git a/packages/raystack/components/prompt-input/index.tsx b/packages/raystack/components/prompt-input/index.tsx index aa01b0f25..ee6cb4045 100644 --- a/packages/raystack/components/prompt-input/index.tsx +++ b/packages/raystack/components/prompt-input/index.tsx @@ -1,3 +1,17 @@ export { PromptInput } from './prompt-input'; -export type { PromptInputStatus } from './prompt-input-context'; -export type { PromptInputRootProps as PromptInputProps } from './prompt-input-root'; +export type { + PromptInputMention, + PromptInputMessage, + PromptInputStatus +} from './prompt-input-context'; +export type { PromptInputEditorProps } from './prompt-input-editor'; +export type { + PromptInputMentionItem, + PromptInputMentionRef +} from './prompt-input-mention-registry'; +export type { PromptInputMentionsProps } from './prompt-input-mentions'; +export type { + PromptInputActions, + PromptInputRootProps as PromptInputProps +} from './prompt-input-root'; +export type { PromptInputTextareaProps } from './prompt-input-textarea'; diff --git a/packages/raystack/components/prompt-input/prompt-input-context.tsx b/packages/raystack/components/prompt-input/prompt-input-context.tsx index 6ac737d33..4cd536f84 100644 --- a/packages/raystack/components/prompt-input/prompt-input-context.tsx +++ b/packages/raystack/components/prompt-input/prompt-input-context.tsx @@ -1,18 +1,78 @@ 'use client'; -import { createContext, RefObject, useContext } from 'react'; +import { createContext, type RefObject, useContext } from 'react'; +import type { EditorMention } from '../editor/mention'; +import type { + PromptInputMentionItem, + PromptInputMentionRegistry +} from './prompt-input-mention-registry'; export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error'; +/** A mention as reported on change and on submit. Offsets index into `text`. */ +export interface PromptInputMention extends EditorMention { + /** Whatever the item carried when it was picked or resolved. */ + data?: unknown; +} + +export interface PromptInputMessage { + /** Plain text with each label inlined behind its trigger. */ + text: string; + /** Round-trippable markup — feeds straight back into `value`. */ + markup: string; + /** Document order; duplicates preserved. */ + mentions: PromptInputMention[]; +} + +/** What the mounted input part derives from its own state. */ +export interface PromptInputValueDetails { + text: string; + mentions: PromptInputMention[]; +} + +/** + * The channel the mounted input part reports through. `Textarea` and `Editor` + * both implement it, so Root never has to know which substrate is underneath. + */ +export interface PromptInputInputApi { + focus: () => void; + /** Push a value that came from outside the part (controlled prop, reset). */ + setMarkup: (markup: string) => void; + /** Text and mentions for a markup string the part has not seen yet. */ + deriveExternal: (markup: string) => PromptInputValueDetails; + /** The live value, read off the part rather than off React state. */ + getMessage: () => PromptInputMessage; + insertMention?: ( + item: PromptInputMentionItem, + options?: { trigger?: string } + ) => void; +} + +export type PromptInputPartKind = 'textarea' | 'editor'; + export interface PromptInputContextValue { + /** Markup — opaque to Root, interpreted only by `Editor`. */ value: string; - setValue: (value: string) => void; + details: PromptInputValueDetails; + /** No mentions, and text that trims to `""`. */ + empty: boolean; + /** Called by the mounted part on every change it makes. */ + setValue: (markup: string, details: PromptInputValueDetails) => void; status: PromptInputStatus; disabled: boolean; onStop?: () => void; inputRef: RefObject; - registerInput: (node: HTMLElement | null) => void; + /** The ``, so the suggestion menu can size itself to the composer. */ + frameRef: RefObject; + registerInput: ( + node: HTMLElement | null, + api?: PromptInputInputApi, + kind?: PromptInputPartKind + ) => void; requestSubmit: () => void; + mentions: PromptInputMentionRegistry; + /** Whether an `Editor` part is mounted — `Mentions` requires one. */ + editorMounted: boolean; } export const PromptInputContext = createContext( @@ -26,3 +86,12 @@ export function usePromptInputContext(part: string): PromptInputContextValue { } return context; } + +/** + * The one emptiness predicate, read by submit gating, `data-empty` and the + * placeholder so they cannot disagree: a message of nothing but a chip is not + * empty, and the space auto-inserted after a chip does not make it non-empty. + */ +export function isEmptyValue(details: PromptInputValueDetails): boolean { + return details.mentions.length === 0 && details.text.trim() === ''; +} diff --git a/packages/raystack/components/prompt-input/prompt-input-editor.tsx b/packages/raystack/components/prompt-input/prompt-input-editor.tsx new file mode 100644 index 000000000..b73a4f246 --- /dev/null +++ b/packages/raystack/components/prompt-input/prompt-input-editor.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; +import { cx } from 'class-variance-authority'; +import { + type ComponentProps, + Fragment, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState +} from 'react'; +import { createPortal } from 'react-dom'; +import { + deriveDocDetails, + docFromMarkup, + editorStyles, + SuggestionMenu, + type SuggestionState, + useEditor +} from '../editor'; +import styles from './prompt-input.module.css'; +import { + type PromptInputInputApi, + usePromptInputContext +} from './prompt-input-context'; +import { useMentionMenu, useMentionResolution } from './use-mention-menu'; + +export interface PromptInputEditorProps + extends Omit< + ComponentProps<'div'>, + 'contentEditable' | 'children' | 'dangerouslySetInnerHTML' | 'role' + > { + /** + * Shown while the composer is empty. + * @defaultValue "Write a message…" + */ + placeholder?: string; + /** Disables just the editor. Inherits the root `disabled` by default. */ + disabled?: boolean; + /** + * Cap on the derived plain text — a chip counts as its label. Enforced by a + * transaction filter, so paste and IME are covered, not just keystrokes. + */ + maxLength?: number; + /** + * Valid on a contentEditable, unlike `maxLength`. + * @defaultValue true + */ + spellCheck?: boolean; +} + +/** + * The ProseMirror sibling to `PromptInput.Textarea`: same outward contract — + * Enter submits, Shift+Enter breaks, placeholder, auto-grow, `disabled`, frame + * focus — on a contentEditable that can host inline mention chips. + */ +export function PromptInputEditor({ + className, + placeholder = 'Write a message…', + disabled, + maxLength, + spellCheck = true, + ref, + ...props +}: PromptInputEditorProps) { + const context = usePromptInputContext('Editor'); + const listboxId = useId(); + const resolvedDisabled = disabled ?? context.disabled; + + const [suggestion, setSuggestion] = useState(null); + const [frameWidth, setFrameWidth] = useState(undefined); + + const setValueRef = useRef(context.setValue); + setValueRef.current = context.setValue; + const requestSubmitRef = useRef(context.requestSubmit); + requestSubmitRef.current = context.requestSubmit; + const registry = context.mentions; + + // Broken out of the option object so the menu, which needs `actions`, can + // still supply the key handler the editor plugin calls. + const keyDownRef = useRef< + ((event: KeyboardEvent, state: SuggestionState) => boolean) | null + >(null); + + const { hostRef, initialHtml, viewRef, mentionPortals, actions } = useEditor({ + initialMarkup: context.value, + placeholder, + disabled: resolvedDisabled, + spellCheck, + maxLength, + getTriggers: () => registry.triggers(), + onChange: details => + setValueRef.current(details.markup, { + text: details.text, + mentions: details.mentions + }), + onSubmit: () => requestSubmitRef.current(), + onSuggestionChange: setSuggestion, + onSuggestionKeyDown: (event, state) => + keyDownRef.current?.(event, state) ?? false + }); + + const menu = useMentionMenu({ + viewRef, + actions, + registry, + suggestion, + disabled: resolvedDisabled, + listboxId + }); + keyDownRef.current = menu.handleKeyDown; + + useMentionResolution(registry, context.details.mentions, actions); + + const api = useMemo( + () => ({ + focus: () => actions.focus(), + setMarkup: markup => actions.setMarkup(markup), + deriveExternal: markup => { + const derived = deriveDocDetails(docFromMarkup(markup)); + return { text: derived.text, mentions: derived.mentions }; + }, + getMessage: () => { + const details = actions.getDetails(); + return { + markup: details.markup, + text: details.text, + mentions: details.mentions + }; + }, + insertMention: (item, options) => { + const trigger = options?.trigger ?? registry.triggers()[0] ?? '@'; + const type = item.type ?? 'mention'; + registry.remember(trigger, { ...item, type }); + actions.insertMention({ + id: item.id, + label: item.label, + type, + trigger + }); + } + }), + [actions, registry] + ); + + const registerInput = context.registerInput; + const register = useCallback( + (node: HTMLDivElement | null) => { + hostRef(node); + registerInput(node, api, 'editor'); + }, + [api, hostRef, registerInput] + ); + + const mergedRef = useMergedRefs(register, ref); + + // A caret is a zero-width anchor, so the menu cannot size itself from + // `--anchor-width`; it takes the composer's width instead, re-measured as the + // panel resizes. Deliberately a passive effect rather than a layout one: the + // frame is this part's ancestor, and React attaches an ancestor's ref *after* + // running a descendant's layout effects — measuring there would read a null + // frame once and never look again. + const frameRef = context.frameRef; + useEffect(() => { + const frame = frameRef.current; + if (!frame || typeof ResizeObserver === 'undefined') return; + const measure = () => { + const width = frame.getBoundingClientRect().width; + setFrameWidth(width > 0 ? width : undefined); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(frame); + return () => observer.disconnect(); + }, [frameRef]); + + const hasMentions = registry.triggers().length > 0; + + return ( + <> +
+ + {mentionPortals.map(portal => { + const item = registry.lookup( + portal.attrs.trigger, + portal.attrs.type, + portal.attrs.id + ); + return ( + + {item?.icon ? createPortal(item.icon, portal.iconTarget) : null} + {item?.trailing + ? createPortal(item.trailing, portal.trailingTarget) + : null} + + ); + })} + + {hasMentions ? ( + { + if (!next) menu.close(); + }} + loading={menu.loading} + loadingRowCount={menu.loadingRowCount} + emptyMessage={menu.emptyMessage} + width={frameWidth} + /> + ) : null} + + ); +} + +PromptInputEditor.displayName = 'PromptInput.Editor'; diff --git a/packages/raystack/components/prompt-input/prompt-input-mention-registry.ts b/packages/raystack/components/prompt-input/prompt-input-mention-registry.ts new file mode 100644 index 000000000..9c342f83d --- /dev/null +++ b/packages/raystack/components/prompt-input/prompt-input-mention-registry.ts @@ -0,0 +1,154 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { mentionKey } from '../editor/mention'; + +export interface PromptInputMentionItem { + id: string; + label: string; + /** + * Entity kind, serialized into the markup. A single `@` menu legitimately + * returns several kinds, which is why this is per item and not per trigger. + * @defaultValue "mention" + */ + type?: string; + icon?: ReactNode; + /** Trailing metadata — a badge, a shortcut, a timestamp. */ + trailing?: ReactNode; + /** Section heading. Groups render in first-appearance order. */ + group?: string; + disabled?: boolean; + /** Opaque; handed back on submit. Never serialized. */ + data?: unknown; +} + +/** A reference parsed out of markup, before it has been resolved. */ +export interface PromptInputMentionRef { + type: string; + id: string; + label: string; +} + +/** Everything `PromptInput.Mentions` contributes for one trigger. */ +export interface PromptInputMentionsData { + items?: PromptInputMentionItem[]; + onSearch?: ( + query: string, + context: { trigger: string; signal: AbortSignal } + ) => Promise; + resolveMentions?: ( + refs: PromptInputMentionRef[] + ) => Promise; + onOpenChange?: (open: boolean) => void; + emptyMessage?: ReactNode; + loadingRowCount?: number; +} + +export interface PromptInputMentionsConfig extends PromptInputMentionsData { + trigger: string; +} + +function sameData( + a: PromptInputMentionsData, + b: PromptInputMentionsData +): boolean { + return ( + a.items === b.items && + a.onSearch === b.onSearch && + a.resolveMentions === b.resolveMentions && + a.onOpenChange === b.onOpenChange && + a.emptyMessage === b.emptyMessage && + a.loadingRowCount === b.loadingRowCount + ); +} + +/** + * Shared between `Mentions` (which writes the config), `Editor` (which reads + * triggers, drives the menu and decorates chips) and Root (which reads `data` + * back when assembling a message). + * + * `icon`, `trailing` and `data` cannot survive serialization, so they live here + * rather than on the document — keyed by `trigger|type|id`, filled in when an + * item is picked from the menu or returned by `resolveMentions`. + * + * Registration is split from data on purpose. The trigger is established once, + * while the data is pushed after every `Mentions` render and compared field by + * field — so an inline `items` array stays live without a config object whose + * identity churns and restarts an in-flight search. + */ +export class PromptInputMentionRegistry { + private configs = new Map(); + private items = new Map(); + private listeners = new Set<() => void>(); + private revision = 0; + + register(trigger: string): () => void { + if (!this.configs.has(trigger)) { + this.configs.set(trigger, { trigger }); + this.emit(); + } + return () => { + if (this.configs.delete(trigger)) this.emit(); + }; + } + + setData(trigger: string, data: PromptInputMentionsData): void { + const current = this.configs.get(trigger); + if (!current || sameData(current, data)) return; + this.configs.set(trigger, { trigger, ...data }); + this.emit(); + } + + get(trigger: string): PromptInputMentionsConfig | undefined { + return this.configs.get(trigger); + } + + triggers(): string[] { + return [...this.configs.keys()]; + } + + remember(trigger: string, item: PromptInputMentionItem): void { + this.rememberAll(trigger, [item]); + } + + rememberAll(trigger: string, items: PromptInputMentionItem[]): void { + if (items.length === 0) return; + for (const item of items) { + const type = item.type ?? 'mention'; + this.items.set(mentionKey(trigger, type, item.id), { ...item, type }); + } + this.emit(); + } + + lookup( + trigger: string, + type: string, + id: string + ): PromptInputMentionItem | undefined { + return this.items.get(mentionKey(trigger, type, id)); + } + + has(trigger: string, type: string, id: string): boolean { + return this.items.has(mentionKey(trigger, type, id)); + } + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + /** + * Bumped by every mutation. Read as a `useSyncExternalStore` snapshot, so a + * reader that mounts after a writer has already emitted still sees the + * change — `Mentions` registers its trigger in an effect that runs before a + * later sibling `Editor` has subscribed. + */ + getRevision = (): number => this.revision; + + private emit() { + this.revision += 1; + for (const listener of this.listeners) listener(); + } +} diff --git a/packages/raystack/components/prompt-input/prompt-input-mentions.tsx b/packages/raystack/components/prompt-input/prompt-input-mentions.tsx new file mode 100644 index 000000000..55f6a3496 --- /dev/null +++ b/packages/raystack/components/prompt-input/prompt-input-mentions.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { type ReactNode, useEffect, useRef } from 'react'; +import { isTriggerCharacter } from '../editor/mention'; +import { usePromptInputContext } from './prompt-input-context'; +import type { + PromptInputMentionItem, + PromptInputMentionRef +} from './prompt-input-mention-registry'; + +export interface PromptInputMentionsProps { + /** + * The character that opens the menu. + * @defaultValue "@" + */ + trigger?: string; + /** Sync data — filtered internally with match-sorter on the label. */ + items?: PromptInputMentionItem[]; + /** + * Async data — debounced ~150 ms, superseded requests aborted through + * `signal`, stale resolutions discarded. Wins over `items`. + */ + onSearch?: ( + query: string, + context: { trigger: string; signal: AbortSignal } + ) => Promise; + /** + * Fills in the icon, trailing content, `data` and a fresh label for chips + * parsed out of `value` / `defaultValue`, which cannot carry them. Batched + * into one call per trigger and cached by `trigger|type|id`. + */ + resolveMentions?: ( + refs: PromptInputMentionRef[] + ) => Promise; + /** Observes the menu's open state. */ + onOpenChange?: (open: boolean) => void; + /** + * Empty-state content. + * @defaultValue "No results" + */ + emptyMessage?: ReactNode; + /** + * Skeleton rows shown while `onSearch` is in flight. + * @defaultValue 3 + */ + loadingRowCount?: number; +} + +/** + * Declares a trigger and supplies its data. Renders nothing itself — the + * caret-anchored menu belongs to `PromptInput.Editor`, which owns the document + * the query lives in, because the query is the text the chip replaces. + * + * Requires `PromptInput.Editor`: next to a `Textarea` it no-ops and warns in + * development, since a native textarea cannot host inline chips. + */ +export function PromptInputMentions({ + trigger = '@', + items, + onSearch, + resolveMentions, + onOpenChange, + emptyMessage, + loadingRowCount +}: PromptInputMentionsProps) { + const context = usePromptInputContext('Mentions'); + const registry = context.mentions; + const editorMounted = context.editorMounted; + + if (process.env.NODE_ENV !== 'production' && !isTriggerCharacter(trigger)) { + console.warn( + `[Apsara] PromptInput.Mentions trigger ${JSON.stringify(trigger)} is not ` + + 'a single punctuation character, so a chip picked from it cannot ' + + 'round-trip through the markup dialect. Use "@", "/", "#" or similar.' + ); + } + + useEffect(() => { + if ( + process.env.NODE_ENV !== 'production' && + registry.triggers().length > 0 && + !registry.get(trigger) + ) { + console.warn( + '[Apsara] PromptInput accepts one in this ' + + 'release. Additional triggers register and work, but only the first ' + + 'is covered by the test suite.' + ); + } + + return registry.register(trigger); + }, [registry, trigger]); + + // `Editor` registers itself during the commit that mounts it, which can land + // after this effect — so the check waits for the tree to settle, and the + // cleanup cancels it the moment an editor does show up. + const sawEditorRef = useRef(false); + useEffect(() => { + if (editorMounted) { + sawEditorRef.current = true; + return; + } + if (process.env.NODE_ENV === 'production' || sawEditorRef.current) return; + const timer = setTimeout(() => { + if (sawEditorRef.current) return; + console.warn( + '[Apsara] PromptInput.Mentions requires ; a ' + + 'native