refactor(client): share the queued-message and goal inline editor
Move the auto-growing textarea into ui-primitives as InlineEditor and use it for both the queued-message and goal editors. It also refits when its width changes and treats keyCode 229 as IME composition.
This commit is contained in:
1 parent
b88b6c248d
commit
66e101821c
18 files changed
+206
-144
No files matched your search
@@ -18,8 +18,8 @@
|
||||
en: 13ae68865fa315d7
|
||||
zh: c152f020abf20f35
|
||||
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
|
||||
en: 7f1d7a29d2238f3a
|
||||
zh: f7a66d2f34a1cd42
|
||||
en: 2f44e26f8ff84268
|
||||
zh: 879534cee58aaed5
|
||||
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
|
||||
en: bb1244310f5c0382
|
||||
zh: 824b84077432b559
|
||||
|
||||
@@ -72,7 +72,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t
|
||||
|
||||
The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
|
||||
|
||||
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
|
||||
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
|
||||
|
||||
Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
|
||||
|
||||
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
|
||||
|
||||
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
|
||||
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
|
||||
|
||||
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
|
||||
|
||||
|
||||
@@ -194,8 +194,7 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview,
|
||||
.editor {
|
||||
.preview {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xs-13);
|
||||
@@ -210,25 +209,6 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.editor {
|
||||
box-sizing: border-box;
|
||||
min-height: 28px;
|
||||
/* Six 20px text lines plus padding and border, then the editor scrolls. */
|
||||
max-height: 127px;
|
||||
padding: 3px 8px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
outline: none;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.editor:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular,
|
||||
FileTypeIcon, fileSizeText, IconEditOutlineRegular, IconQueueOutlineRegular, IconSendOutlineRegular,
|
||||
FileTypeIcon, fileSizeText, IconEditOutlineRegular, InlineEditor, IconQueueOutlineRegular, IconSendOutlineRegular,
|
||||
IconTrashOutlineRegular, projectUserText, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
@@ -98,53 +98,6 @@ function QueueThumb({ attachment, loadImage, label }: {
|
||||
: <img className={css.thumb} src={url} alt={label} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline editor for one queued row. A textarea rather than an input: HTML
|
||||
* strips newlines from single-line input values, so editing a multi-line
|
||||
* queued message through one rewrites it as a single line. It grows with its
|
||||
* content up to the CSS cap, then scrolls. Enter saves, Shift+Enter breaks the
|
||||
* line, Escape cancels.
|
||||
*/
|
||||
function QueueEditor({ text, label, onChange, onSave, onCancel }: {
|
||||
text: string
|
||||
label: string
|
||||
onChange: (text: string) => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current
|
||||
/* v8 ignore next -- the ref is attached before layout effects run. */
|
||||
if (node === null) return
|
||||
node.style.height = 'auto'
|
||||
// scrollHeight excludes the border that the border-box height includes.
|
||||
node.style.height = `${node.scrollHeight + node.offsetHeight - node.clientHeight}px`
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
autoFocus
|
||||
rows={1}
|
||||
className={css.editor}
|
||||
aria-label={label}
|
||||
value={text}
|
||||
onChange={(event) => { onChange(event.currentTarget.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
|
||||
event.preventDefault()
|
||||
onSave()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
|
||||
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'>
|
||||
|
||||
@@ -248,8 +201,8 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
|
||||
{rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutlineRegular /></span>}
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<QueueEditor
|
||||
text={editing.text}
|
||||
<InlineEditor
|
||||
value={editing.text}
|
||||
label={t('queue.edit')}
|
||||
onChange={(text) => { setEditing({ id: row.id, text }) }}
|
||||
onSave={() => { void saveEdit() }}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-goal/use-this-package:
|
||||
en: c1a080517f96a03e
|
||||
zh: c0bc769c18d08662
|
||||
en: 4f6e6353d793a0de
|
||||
zh: cde6e470f7dd9057
|
||||
/deepseek-ai-dsh-client-ui-goal/use-this-package/the-command-input-bubble:
|
||||
en: 05c5db0eb58dffef
|
||||
zh: 54e7bea0b810b1b8
|
||||
|
||||
@@ -25,7 +25,7 @@ The Web GUI goal surface shows both the durable goal state and its current proce
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective in a multi-line editor that grows up to six lines (Enter saves, Shift+Enter breaks the line, Escape cancels); clear removes the goal and suppresses the strip until the projection catches up.
|
||||
Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective in the ui-primitives `InlineEditor`, so multi-line objectives survive editing; clear removes the goal and suppresses the strip until the projection catches up.
|
||||
|
||||
### The command-input bubble
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑在最多增高到六行的多行编辑框中重写目标文本(Enter 保存,Shift+Enter 换行,Escape 取消);清除移除目标,并在投影追上之前抑制条带。
|
||||
与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑在 ui-primitives 的 `InlineEditor` 中重写目标文本,多行目标编辑后仍保留换行;清除移除目标,并在投影追上之前抑制条带。
|
||||
|
||||
### 指令输入气泡
|
||||
|
||||
|
||||
@@ -90,40 +90,11 @@
|
||||
height: auto;
|
||||
min-height: 36px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.objectiveInput {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 26px;
|
||||
/* Six 20px text lines plus padding and border, then the editor scrolls. */
|
||||
max-height: 125px;
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
/* The editor scrolls inside the elevated menu-surface strip. */
|
||||
/* The editor scrolls inside this elevated menu-surface strip. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.objectiveInput:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.objectiveInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* ---- Icon actions ---- */
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* process-local activation arrives through the injected activation hook.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { GoalActivation, GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
|
||||
import {
|
||||
IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular, IconGoalOutlineRegular,
|
||||
IconPauseOutlineRegular, IconPlayOutlineRegular, IconTrashOutlineRegular, Tooltip,
|
||||
IconPauseOutlineRegular, IconPlayOutlineRegular, IconTrashOutlineRegular, InlineEditor, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { GoalActionResult, GoalBarActions, GoalBarInjected } from './slots.ts'
|
||||
@@ -46,17 +46,6 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [clearedGoalId, setClearedGoalId] = useState<GoalSnapshot['id'] | null>(null)
|
||||
const pendingRef = useRef(false)
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// The editor grows with the draft up to the CSS cap, then scrolls. An inline
|
||||
// error shares the editor's row, so its arrival or removal changes the width.
|
||||
useLayoutEffect(() => {
|
||||
const node = editorRef.current
|
||||
if (node === null) return
|
||||
node.style.height = 'auto'
|
||||
// scrollHeight excludes the border that the border-box height includes.
|
||||
node.style.height = `${node.scrollHeight + node.offsetHeight - node.clientHeight}px`
|
||||
}, [draft, editing, actionError])
|
||||
|
||||
// A new goal identity (cleared/completed/replaced externally) invalidates the local edit
|
||||
// state: without the reset a surviving draft's Enter would write over the NEW goal.
|
||||
@@ -100,24 +89,12 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
|
||||
return (
|
||||
<div className={css.dock} data-goal-bar>
|
||||
<div className={`${css.bar} ${css.editBar}`}>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
className={css.objectiveInput}
|
||||
rows={1}
|
||||
aria-label={t('objective.aria')}
|
||||
<InlineEditor
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
// Shift+Enter breaks the line; IME confirmation keeps its commit behavior.
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
const composing = e.nativeEvent.isComposing || Reflect.get(e.nativeEvent, 'keyCode') === 229
|
||||
if (e.key === 'Enter' && !e.shiftKey && !composing) {
|
||||
e.preventDefault()
|
||||
void handleEdit()
|
||||
}
|
||||
if (e.key === 'Escape') setEditing(false)
|
||||
}}
|
||||
autoFocus
|
||||
label={t('objective.aria')}
|
||||
onChange={setDraft}
|
||||
onSave={() => { void handleEdit() }}
|
||||
onCancel={() => { setEditing(false) }}
|
||||
/>
|
||||
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
|
||||
<div className={css.actions}>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 86ebc31d3af22478
|
||||
zh: 6f9f75d43e67049e
|
||||
en: fcd8f6ea69213df3
|
||||
zh: d4aca577b815b740
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: b4cd33720a6cf6b8
|
||||
zh: 8db75bf633789c6d
|
||||
|
||||
@@ -45,6 +45,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`<id>-<value>`) and the panel it controls (`<id>-<value>-panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. |
|
||||
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
|
||||
| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
|
||||
| `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. |
|
||||
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
|
||||
| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
|
||||
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
||||
|
||||
@@ -45,6 +45,7 @@ kind: "package-library"
|
||||
| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`<id>-<value>`)及其控制的面板 id(`<id>-<value>-panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 |
|
||||
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
|
||||
| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 |
|
||||
| `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 |
|
||||
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
||||
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
|
||||
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.editor {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
/* Six 20px text lines plus padding and border, then the editor scrolls. */
|
||||
max-height: 127px;
|
||||
margin: 0;
|
||||
padding: 3px 8px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
outline: none;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-family: Inter, var(--dsw-font-family);
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.editor:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Multi-line inline editor for short saved-on-Enter drafts such as queued messages and goals. */
|
||||
|
||||
import { useLayoutEffect, useRef, type KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import css from './InlineEditor.module.css'
|
||||
|
||||
/**
|
||||
* Render an autofocused textarea that grows with its text up to six lines, then scrolls.
|
||||
* A textarea rather than an input: HTML strips newlines from single-line input values.
|
||||
* Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing,
|
||||
* and Escape cancels.
|
||||
* @param props.value - current draft text.
|
||||
* @param props.label - localized accessible name.
|
||||
* @param props.onChange - receives the edited text.
|
||||
* @param props.onSave - called for a plain Enter.
|
||||
* @param props.onCancel - called for Escape.
|
||||
* @param props.className - extra class for the editor's placement.
|
||||
* @returns the textarea.
|
||||
*/
|
||||
export function InlineEditor({ value, label, onChange, onSave, onCancel, className }: {
|
||||
value: string
|
||||
label: string
|
||||
onChange: (value: string) => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
className?: string
|
||||
}) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// The textarea mounts before layout effects run.
|
||||
const node = ref.current as HTMLTextAreaElement
|
||||
const fit = (): void => {
|
||||
node.style.height = 'auto'
|
||||
// scrollHeight excludes the border that the border-box height includes.
|
||||
node.style.height = `${node.scrollHeight + node.offsetHeight - node.clientHeight}px`
|
||||
}
|
||||
fit()
|
||||
// A width change rewraps the text; the refit height leaves the width unchanged.
|
||||
const observer = new ResizeObserver(fit)
|
||||
observer.observe(node)
|
||||
return () => { observer.disconnect() }
|
||||
}, [value])
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (event.key === 'Escape') {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
const composing = event.nativeEvent.isComposing || Reflect.get(event.nativeEvent, 'keyCode') === 229
|
||||
if (event.key !== 'Enter' || event.shiftKey || composing) return
|
||||
event.preventDefault()
|
||||
onSave()
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
autoFocus
|
||||
rows={1}
|
||||
className={clsx(css.editor, className)}
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(event) => { onChange(event.currentTarget.value) }}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Input: single-line text input atom (search boxes, inline forms). Composer
|
||||
// textareas are NOT this atom — they live with the conversation package.
|
||||
// Input: single-line text input atom (search boxes, inline forms). Multi-line
|
||||
// inline edits use InlineEditor; the composer is owned by the conversation package.
|
||||
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -22,6 +22,7 @@ export { SegmentedControl } from './SegmentedControl.tsx'
|
||||
export type { SegmentedControlOption } from './SegmentedControl.tsx'
|
||||
export { Checkbox } from './Checkbox.tsx'
|
||||
export { Input } from './Input.tsx'
|
||||
export { InlineEditor } from './InlineEditor.tsx'
|
||||
export { Menu, MenuItemButton } from './Menu.tsx'
|
||||
export { MenuSurface } from './MenuSurface.tsx'
|
||||
export type { MenuSurfaceProps } from './MenuSurface.tsx'
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InlineEditor } from '../src/InlineEditor.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderEditor(value = 'draft') {
|
||||
const handlers = { onChange: vi.fn(), onSave: vi.fn(), onCancel: vi.fn() }
|
||||
const view = render(<InlineEditor value={value} label="Edit" {...handlers} />)
|
||||
return { ...handlers, view, box: screen.getByRole<HTMLTextAreaElement>('textbox', { name: 'Edit' }) }
|
||||
}
|
||||
|
||||
describe('InlineEditor', () => {
|
||||
it('focuses a one-row textarea that keeps line breaks', () => {
|
||||
const { box, onChange } = renderEditor('one\ntwo')
|
||||
expect(document.activeElement).toBe(box)
|
||||
expect(box.rows).toBe(1)
|
||||
expect(box.value).toBe('one\ntwo')
|
||||
fireEvent.change(box, { target: { value: 'one\ntwo\nthree' } })
|
||||
expect(onChange).toHaveBeenCalledWith('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('saves on plain Enter only, and cancels on Escape', () => {
|
||||
const { box, onSave, onCancel } = renderEditor()
|
||||
expect(fireEvent.keyDown(box, { key: 'Enter', shiftKey: true })).toBe(true)
|
||||
fireEvent.keyDown(box, { key: 'Enter', isComposing: true })
|
||||
fireEvent.keyDown(box, { key: 'Enter', keyCode: 229 })
|
||||
fireEvent.keyDown(box, { key: 'a' })
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
|
||||
const enter = createEvent.keyDown(box, { key: 'Enter' })
|
||||
fireEvent(box, enter)
|
||||
expect(enter.defaultPrevented).toBe(true)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.keyDown(box, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sizes to its content plus border, and refits when its width changes', () => {
|
||||
class FakeResizeObserver implements ResizeObserver {
|
||||
static latest: FakeResizeObserver | undefined
|
||||
readonly observe = vi.fn()
|
||||
readonly unobserve = vi.fn()
|
||||
readonly disconnect = vi.fn()
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
FakeResizeObserver.latest = this
|
||||
}
|
||||
|
||||
fire(): void {
|
||||
this.callback([], this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
||||
let scrollHeight = 20
|
||||
const descriptors = (['scrollHeight', 'offsetHeight', 'clientHeight'] as const)
|
||||
.map(name => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as const)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, get: () => scrollHeight })
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 27 })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 26 })
|
||||
try {
|
||||
const { box, view } = renderEditor()
|
||||
expect(box.style.height).toBe('21px')
|
||||
const observer = FakeResizeObserver.latest
|
||||
if (observer === undefined) throw new Error('expected the editor to observe its size')
|
||||
expect(observer.observe).toHaveBeenCalledWith(box)
|
||||
|
||||
scrollHeight = 60
|
||||
act(() => { observer.fire() })
|
||||
expect(box.style.height).toBe('61px')
|
||||
view.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
else Object.defineProperty(HTMLElement.prototype, name, descriptor)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user