Skip to content

Commit 107dc0a

Browse files
feat(composer): preserve structured references and support actions (#872)
* feat(composer): preserve structured references and support action callbacks * style(composer): express CSS variables in rem * revert(composer): restore pixel CSS variable values * test(composer): remove document tests
1 parent 61eb293 commit 107dc0a

21 files changed

Lines changed: 494 additions & 112 deletions

‎apps/ui/content/docs/components/composer.mdx‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ To extend the anatomy above, pass `triggers={triggers}` to `Composer` and insert
188188
</ComposerPopup>
189189
```
190190

191-
Configure only the triggers you need. Queries can contain spaces: `@sam r` finds Sam Rivera. A space immediately after the trigger closes the suggestions. Selecting an item inserts a token into the draft; it does not send the message or execute an action.
191+
Configure only the triggers you need. Queries can contain spaces: `@sam r` finds Sam Rivera. A space immediately after the trigger closes the suggestions. By default, selecting an item inserts a token into the draft; it does not send the message or execute an action. Supply `onAction` to handle items with `type: "action"` as commands: Composer removes the active query and calls your handler without inserting a token or submitting. Your application owns execution.
192192

193193
To open a trigger from a button inside `ComposerActions`, call `useComposerContext().insertTrigger("@")` or `insertTrigger("/")`. The trigger is inserted at the caret and opens the same suggestions as typing it.
194194

@@ -207,7 +207,7 @@ By default, selected mentions and actions appear as `text-primary font-medium` t
207207

208208
<ComponentPreview name="p-composer-6" />
209209

210-
The selected item's `kind` reaches `renderToken`, so a person and a team selected from the same `@` menu can use different icons or styles. The structured draft keeps `type`, `kind`, `id`, `label`, and `trigger`; `getComposerText(value)` still returns text such as `@Sam Rivera` when a chip hides the trigger visually. An item's `icon` is available to the suggestion list renderer but is not stored in the draft. A native textarea remains plain text and does not render tokens.
210+
The selected item's `kind` reaches `renderToken`, so a person and a team selected from the same `@` menu can use different icons or styles. The structured draft keeps `type`, `kind`, `id`, `label`, `trigger`, and optional `data` and `text`; `getComposerText(value)` returns the explicit `text` or defaults to text such as `@Sam Rivera`, even when a chip hides the trigger visually. An item's `icon` is available to the suggestion list renderer but is not stored in the draft. A native textarea remains plain text and does not render tokens.
211211

212212
### Custom filtering
213213

@@ -217,13 +217,15 @@ The default filter ignores case and accents. Set a trigger's `filter` to customi
217217

218218
### Async results
219219

220-
Fetch results in response to `onQueryChange`. Use `filter: null` for prefiltered results and `itemsFor` to identify the query they belong to. A completed query with no matches closes the popup.
220+
Fetch results in response to `onQueryChange`. Use `filter: null` for prefiltered results and `itemsFor` to identify their query text. For caret-sensitive results, return the opaque `ComposerQuery.key` as the trigger’s `queryKey`; results from a different query position cannot be selected even when the text matches. Do not persist or parse the key.
221+
222+
A completed query with no matches closes the popup by default. Set `showEmpty: true` to keep it open and render an empty state with `ComposerStatus`. `maxQueryLength` optionally limits the query text after the trigger. When a popup has no selectable results, Enter is blocked by default; `submitOnEmptySuggestions` lets Enter follow the normal submission policy, including during loading or error states.
221223

222224
<ComponentPreview name="p-composer-2" />
223225

224226
### Keyboard shortcuts
225227

226-
While suggestions are open, ↑ and ↓ navigate, Enter selects, Escape dismisses, and Tab moves focus normally. Otherwise, Enter submits and Shift+Enter inserts a line break in the native contenteditable input or textarea. Set `submitOnEnter="desktop"` to submit only with a fine pointer, or `"never"` to let Enter insert a line break.
228+
While suggestions are open, ↑ and ↓ navigate, Enter selects, Escape dismisses, and Tab moves focus normally. Shift+Enter dismisses suggestions and inserts a line break. Otherwise, Enter submits and Shift+Enter inserts a line break in the native contenteditable input or textarea. Set `submitOnEnter="desktop"` to submit only with a fine pointer, or `"never"` to let Enter insert a line break.
227229

228230
## In a drawer
229231

@@ -246,7 +248,9 @@ The styled frame and state owner for one input. It renders a form by default and
246248
| `defaultValue` | `ComposerValue` | `[]` | Initial uncontrolled draft |
247249
| `onValueChange` | `(value: ComposerValue, details: ComposerChangeDetails) => void` | — | Called with the next draft and the change reason |
248250
| `triggers` | `readonly ComposerTrigger[]` | `[]` | Suggestion sources for contenteditable; omit for plain text |
249-
| `onQueryChange` | `(query: { character: string; text: string } \| null) => void` | — | Active suggestion query |
251+
| `onQueryChange` | `(query: ComposerQuery \| null) => void` | — | Active suggestion query |
252+
| `onAction` | `(item: ComposerItemValue, query: ComposerQuery) => void` | — | Removes the query and reports an action selection without inserting a token or submitting |
253+
| `submitOnEmptySuggestions` | `boolean` | `false` | Allows Enter to follow the submission policy when the open popup has no selectable results |
250254
| `submitOnEnter` | `"desktop" \| "always" \| "never"` | `"always"` | Enter behavior for either input |
251255
| `layout` | `"stacked" \| "adaptive"` | `"stacked"` | Keep actions below the input, or place them beside a one-line draft and move them below when it wraps |
252256
| `disabled` | `boolean` | `false` | Disables editing, suggestions, and submission |
@@ -417,7 +421,7 @@ type ComposerValue = readonly ComposerPart[]
417421

418422
type ComposerPart =
419423
| { type: "text"; text: string }
420-
| { type: "mention" | "action"; id: string; label: string; trigger: string; kind?: string }
424+
| { type: "mention" | "action"; id: string; label: string; trigger: string; kind?: string; data?: string; text?: string }
421425

422426
type ComposerTokenPart = Exclude<ComposerPart, { type: "text" }>
423427

@@ -429,10 +433,14 @@ type ComposerItemValue = {
429433
icon?: ReactNode
430434
type?: "mention" | "action"
431435
kind?: string
436+
data?: string
437+
text?: string
432438
}
439+
440+
type ComposerQuery = { character: string; text: string; key: string }
433441
```
434442
435-
Item IDs must be unique within each trigger. Set `disabled: true` on an item to skip it during keyboard navigation and prevent selection. `type` defaults to `"mention"`; set `"action"` for slash actions. Use `kind` for your own categories, such as `"person"` and `"team"`, even when both come from `@`. The selected part retains `kind`. `getComposerText(value)` returns the plain-text representation with trigger characters, including when chips hide them visually.
443+
Item IDs must be unique within each trigger. Set `disabled: true` on an item to skip it during keyboard navigation and prevent selection. `type` defaults to `"mention"`; set `"action"` for slash actions. Use `kind` for your own categories, such as `"person"` and `"team"`, even when both come from `@`. The selected part retains `kind`. `getComposerText(value)` uses a token’s optional `text` field, or falls back to its trigger and label. Optional `data` is an opaque string preserved through editing, undo/redo, and supported structured copy/paste; the editor never interprets it as HTML. Keep domain validation and authorization in your application.
436444
437445
`ComposerTrigger` configures one source:
438446
@@ -442,11 +450,14 @@ Item IDs must be unique within each trigger. Set `disabled: true` on an item to
442450
| `items` | `readonly ComposerItemValue[]` | required | Candidate results |
443451
| `filter` | `((item, query) => boolean) \| null` | label contains query | Custom matching; `null` uses prefiltered results |
444452
| `itemsFor` | `string` | — | Query the async items belong to |
445-
| `status` | `"ready" \| "loading" \| "error"` | `"ready"` | Loading/error feedback keeps the popup open; zero ready matches hide it |
453+
| `queryKey` | `string` | — | Opaque query identity returned by `onQueryChange`; mismatched results cannot be selected |
454+
| `showEmpty` | `boolean` | `false` | Keeps the popup open when there are no ready matches |
455+
| `maxQueryLength` | `number` | Unlimited | Maximum query text length after the trigger |
456+
| `status` | `"ready" \| "loading" \| "error"` | `"ready"` | Loading/error feedback keeps the popup open; zero ready matches hide it unless `showEmpty` is enabled |
446457
447458
### Draft lifecycle
448459
449-
Copying or cutting a token includes its trigger in plain text, even when a chip hides it. Pasted or dropped text stays plain text and never acquires a mention ID. Keep application records keyed by the token's `id` and `kind`; display icons are not part of the submitted document.
460+
Copying or cutting includes the token’s `text`, or its trigger and label by default, in plain text. It also writes `application/x-coss-composer` data so compatible composers can preserve tokens and metadata. Structured paste validates the document shape and accepts at most 1,000 parts and 100,000 serialized characters; unsupported or invalid structured data falls back to plain text. External text, HTML, and dropped text do not acquire mention IDs. Clipboard metadata is untrusted input and never grants access to application records. Display icons are not part of the submitted document.
450461
451462
Contenteditable edits have a local undo history, including token insertion, paste, and cut. Replacing the draft externally or resetting it starts a new history. Selection is preserved and clamped to the updated draft. If an external value changes during IME composition, it is applied after composition finishes. A native textarea keeps its browser editing history.
452463
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
3+
"name": "composer-clipboard",
4+
"registryDependencies": [
5+
"@coss/composer-document"
6+
],
7+
"files": [
8+
{
9+
"path": "registry/default/lib/composer-clipboard.ts",
10+
"content": "import type { ComposerPart, ComposerValue } from \"./composer-document\";\n\nexport const COMPOSER_CLIPBOARD_TYPE = \"application/x-coss-composer\";\n\nexport function parseComposerClipboard(raw: string): ComposerValue | null {\n if (!raw || raw.length > 100_000) return null;\n try {\n const value: unknown = JSON.parse(raw);\n if (!Array.isArray(value) || value.length > 1_000) return null;\n const parts: ComposerPart[] = [];\n for (const part of value) {\n if (!part || typeof part !== \"object\") return null;\n if (part.type === \"text\" && typeof part.text === \"string\") {\n parts.push({ type: \"text\", text: part.text });\n continue;\n }\n if (\n (part.type !== \"mention\" && part.type !== \"action\") ||\n typeof part.id !== \"string\" ||\n typeof part.label !== \"string\" ||\n typeof part.trigger !== \"string\"\n )\n return null;\n parts.push({\n type: part.type,\n id: part.id,\n label: part.label,\n trigger: part.trigger,\n ...(typeof part.kind === \"string\" ? { kind: part.kind } : {}),\n ...(typeof part.data === \"string\" ? { data: part.data } : {}),\n ...(typeof part.text === \"string\" ? { text: part.text } : {}),\n });\n }\n return parts;\n } catch {\n return null;\n }\n}\n",
11+
"type": "registry:lib"
12+
}
13+
],
14+
"type": "registry:lib"
15+
}

0 commit comments

Comments
 (0)