fix(web): refine model search threshold and focus restoration
This commit is contained in:
1 parent
45368fb158
commit
bfec75eb14
17 files changed
+376
-71
No files matched your search
@@ -115,7 +115,8 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
|
||||
expect(slashPickerBox).not.toBeNull()
|
||||
expect(composerBox).not.toBeNull()
|
||||
expect(slashPickerBox!.width).toBeGreaterThanOrEqual(220)
|
||||
expect(composerBox!.width - slashPickerBox!.width).toBeGreaterThan(1)
|
||||
expect(slashPickerBox!.width).toBeCloseTo(composerBox!.width)
|
||||
expect(slashPickerBox!.x).toBeCloseTo(composerBox!.x)
|
||||
expect(Math.abs(composerBox!.y - slashPickerBox!.y - slashPickerBox!.height - 4)).toBeLessThan(1)
|
||||
|
||||
await slashPicker.getByRole('option', { name: 'Auto review EXP' }).click()
|
||||
|
||||
@@ -46,6 +46,7 @@ describe.skipIf(MODE === 'record').each([
|
||||
models: [
|
||||
{ id: 'acme-think', name: 'Acme Think' },
|
||||
{ id: 'acme-swift', name: 'Acme Swift' },
|
||||
{ id: 'acme-lite', name: 'Acme Lite' },
|
||||
].map(model => ({
|
||||
...model,
|
||||
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: MODEL, name: 'Acme Large' }],
|
||||
models: [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -135,6 +135,52 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
}
|
||||
})
|
||||
|
||||
it('hides search only in the button menu when four models remain', async () => {
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
const setModels = (expanded: boolean) => scaffold.ctx.settings.update('llm-pi-ai', {
|
||||
providers: { [ROUTE]: {
|
||||
displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1',
|
||||
models: expanded ? [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }]
|
||||
: [{ id: MODEL, name: 'Acme Large' }],
|
||||
} },
|
||||
})
|
||||
try {
|
||||
await setModels(false)
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(4)
|
||||
expect(await search.count()).toBe(0)
|
||||
const current = page.getByRole('menuitemradio', { name: 'Origin Large', exact: true })
|
||||
await expect.poll(() => current.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
const rows = page.getByRole('menuitemradio')
|
||||
const currentId = await current.getAttribute('id')
|
||||
for (let index = 0; index < 4; index++) await page.keyboard.press('ArrowDown')
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe(currentId)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => trigger.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await trigger.evaluate(node => getComputedStyle(node).boxShadow)).toBe('none')
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const commandSearch = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await commandSearch.waitFor()
|
||||
expect(await commandSearch.getAttribute('placeholder')).toBe('搜索模型…')
|
||||
await commandSearch.press('Escape')
|
||||
await page.locator('[data-composer-input]').first().fill('')
|
||||
expect(await rows.count()).toBe(0)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await setModels(true)
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(5)
|
||||
await search.waitFor()
|
||||
await search.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
|
||||
it('paints only pinned provider headings across themes, filtering, and reopening', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-sticky-headings'))
|
||||
const originalViewport = page.viewportSize()!
|
||||
@@ -344,6 +390,8 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false')
|
||||
await expect.poll(() => trigger.textContent()).toContain('Acme Large')
|
||||
await expect.poll(() => trigger.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).toBe('none')
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/background-save.expected.md', import.meta.url)), aria, webSnapshotMode())
|
||||
@@ -369,6 +417,10 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
.toEqual({ provider: ROUTE, model: MODEL })
|
||||
// ...while the one holding a logged route keeps deriving from its log.
|
||||
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
await trigger.press('Tab')
|
||||
await trigger.focus()
|
||||
expect(await trigger.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).not.toBe('none')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Think"
|
||||
- menuitemradio "Acme Swift" [checked]
|
||||
- menuitemradio "Acme Lite"
|
||||
@@ -1,5 +1,4 @@
|
||||
- group "模型与推理等级":
|
||||
- searchbox "搜索模型…"
|
||||
- menu "模型":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
- listbox "/model 匹配项":
|
||||
- option "DeepSeek-V4-Flash DeepSeek"
|
||||
- option "DeepSeek-V4-Flash-Vision-Exp DeepSeek"
|
||||
- option "Origin Large Origin Gateway" [selected]
|
||||
- option "Acme Large Acme Gateway"
|
||||
- option "Origin Large Origin Gateway"
|
||||
- option "Acme Large Acme Gateway" [selected]
|
||||
- option "Acme Small Acme Gateway"
|
||||
@@ -15,11 +15,11 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package:
|
||||
en: 72049644af237ff7
|
||||
zh: 6454d8d8ffe656ff
|
||||
en: 62235f8c8c9e1e7e
|
||||
zh: c18dc9c38ef9d4f9
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
|
||||
en: e2524ba86fd82c1c
|
||||
zh: 01162681bce9be2d
|
||||
en: 1abd83031efe3051
|
||||
zh: ac85bd0ff44a9505
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions:
|
||||
en: 7f20428c747ff052
|
||||
zh: 975e6de728e02fa0
|
||||
|
||||
@@ -31,13 +31,13 @@ DeepSeek account and API-key routes appear as separate provider groups, each exp
|
||||
|
||||
The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it.
|
||||
|
||||
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses the model search field or the current effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged.
|
||||
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged.
|
||||
|
||||
Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open. Tab returns to model search with the current model highlighted, or to the current row in other panes.
|
||||
Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. Selecting a model or effort returns focus to the trigger without a focus ring; leaving the trigger or reopening the menu restores its normal focus indication. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open. Tab returns to model search with the current model highlighted when search is shown, otherwise to the current row.
|
||||
|
||||
### Model and effort
|
||||
|
||||
The search field at the top of the composer model list matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query.
|
||||
The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. When shown, the button menu's search field matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query.
|
||||
|
||||
Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field.
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
|
||||
|
||||
未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。
|
||||
|
||||
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻聚焦模型搜索框或当前推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。
|
||||
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。
|
||||
|
||||
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。Tab 在模型面板返回搜索框并高亮当前模型,在其他面板返回当前选中行。
|
||||
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。选定模型或推理等级后,焦点返回触发按钮但不显示焦点环;焦点离开按钮或重新打开菜单后恢复正常焦点提示。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。显示模型搜索框时,Tab 返回搜索框并高亮当前模型,否则返回当前选中行。
|
||||
|
||||
### 模型与推理强度
|
||||
|
||||
composer 模型列表顶部的搜索框按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。
|
||||
按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。按钮菜单显示搜索框时,按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。
|
||||
|
||||
composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
.trigger:focus-visible:not([data-selection-focus]) {
|
||||
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
@@ -199,13 +199,17 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.search input {
|
||||
.searchRow .search:focus-within {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.searchRow .search input {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.search input::placeholder {
|
||||
.searchRow .search input::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
* each drilling into its own list — the provider-grouped model list over
|
||||
* the shared directory, and the effort levels. The trigger (313:14108's
|
||||
* ToggleButton) shows both: model name + effort in the caption tone.
|
||||
* In the model pane, search retains focus while ↑/↓ cycle the highlighted
|
||||
* result; Enter and Tab accept it. Root and effort panes move focus between
|
||||
* rows. Escape and Shift+Tab leave a drilled pane first and otherwise close
|
||||
* Model catalogs above four entries show search, which retains focus while
|
||||
* ↑/↓ cycle the highlighted result; Enter and Tab accept it. Smaller model
|
||||
* catalogs, root panes, and effort panes move focus between rows. Escape and Shift+Tab leave a drilled pane first and otherwise close
|
||||
* back to the trigger. A drilled pane focuses the current effort or model
|
||||
* search field. Provider headings paint their background only while pinned
|
||||
* by scrolling. Clearing a query restores the full list and search focus.
|
||||
* Model names match a
|
||||
* case-insensitive ordered subsequence within each provider group, ranked by
|
||||
* Selecting restores trigger focus without a ring until the trigger loses focus
|
||||
* or the menu reopens. Model names match a case-insensitive ordered subsequence
|
||||
* within each provider group, ranked by
|
||||
* prefix, alignment score, then catalog order. Returning to the root pane
|
||||
* hands focus back to the cell that opened it. Data and submission ride the
|
||||
* same per-session ModelDirectory as the /model popup; exact-model reasoning
|
||||
@@ -71,6 +72,7 @@ export function ModelSelect(
|
||||
const [pane, setPane] = useState<Pane>('root')
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null)
|
||||
const [selectionFocus, setSelectionFocus] = useState(false)
|
||||
// The in-menu error strip serves catalog loads (its Retry re-runs the
|
||||
// load); a rejected SELECTION announces through the transient toast
|
||||
// instead, so the strip renders only while the latest failure-capable
|
||||
@@ -80,6 +82,7 @@ export function ModelSelect(
|
||||
const toastSeq = useRef(0)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const searchRef = useRef<HTMLInputElement | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||
const groupsRef = useRef<HTMLDivElement | null>(null)
|
||||
const [menuPos, setMenuPos] = useState<CSSProperties | null>(null)
|
||||
@@ -101,9 +104,10 @@ export function ModelSelect(
|
||||
: { reasoningEffort: model.reasoning.defaultEffort },
|
||||
} satisfies ModelSelection,
|
||||
}))), [groups])
|
||||
const showSearch = choices.length > 4
|
||||
const filteredGroups = useMemo(() => groups.map(group => ({
|
||||
...group, models: rankByName(group.models, query.trim()),
|
||||
})).filter(group => group.models.length > 0), [groups, query])
|
||||
...group, models: rankByName(group.models, showSearch ? query.trim() : ''),
|
||||
})).filter(group => group.models.length > 0), [groups, query, showSearch])
|
||||
const visibleModels = useMemo(() => filteredGroups.flatMap(group => group.models.map(model => ({
|
||||
provider: group.id, model: model.id,
|
||||
}))), [filteredGroups])
|
||||
@@ -153,16 +157,26 @@ export function ModelSelect(
|
||||
return () => { document.removeEventListener('mousedown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showSearch) {
|
||||
setQuery('')
|
||||
setHighlightedIndex(null)
|
||||
}
|
||||
}, [showSearch])
|
||||
|
||||
// Pane switches unmount the focused row; restore focus inside the menu so
|
||||
// keyboard navigation remains available.
|
||||
const paneFocus = useRef<'drill' | 'model' | 'effort' | null>(null)
|
||||
const previousShowSearch = useRef(showSearch)
|
||||
useEffect(() => {
|
||||
const intent = paneFocus.current
|
||||
const changedSearchMode = previousShowSearch.current !== showSearch
|
||||
previousShowSearch.current = showSearch
|
||||
const intent = paneFocus.current ?? (changedSearchMode && pane === 'model' ? 'drill' : null)
|
||||
paneFocus.current = null
|
||||
if (!open || intent === null) return
|
||||
if (intent === 'drill') {
|
||||
if (pane === 'model') {
|
||||
menuRef.current?.querySelector('input')?.focus()
|
||||
if (pane === 'model' && showSearch) {
|
||||
searchRef.current?.focus()
|
||||
return
|
||||
}
|
||||
// The checked row is the value in use; a pane without one opens on its
|
||||
@@ -176,7 +190,7 @@ export function ModelSelect(
|
||||
}
|
||||
const cell = itemRefs.current[intent === 'effort' ? 1 : 0]
|
||||
;(cell !== null && cell !== undefined && !cell.disabled ? cell : triggerRef.current)?.focus()
|
||||
}, [open, pane])
|
||||
}, [open, pane, showSearch])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewport = groupsRef.current
|
||||
@@ -250,6 +264,7 @@ export function ModelSelect(
|
||||
if (!available) return null
|
||||
|
||||
const show = (): void => {
|
||||
setSelectionFocus(false)
|
||||
triggerRef.current?.focus()
|
||||
setQuery('')
|
||||
setHighlightedIndex(null)
|
||||
@@ -270,6 +285,11 @@ export function ModelSelect(
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
|
||||
const closeAfterSelection = (): void => {
|
||||
setSelectionFocus(true)
|
||||
close(true)
|
||||
}
|
||||
|
||||
const drill = (next: Pane): void => {
|
||||
setQuery('')
|
||||
setHighlightedIndex(null)
|
||||
@@ -306,16 +326,16 @@ export function ModelSelect(
|
||||
return
|
||||
}
|
||||
if (!open) return
|
||||
if (pane === 'model' && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
||||
if (pane === 'model' && showSearch && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
||||
event.preventDefault()
|
||||
if (!busy && visibleModels.length > 0) {
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1
|
||||
setHighlightedIndex((activeModelIndex + direction + visibleModels.length) % visibleModels.length)
|
||||
menuRef.current?.querySelector('input')?.focus()
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (pane === 'model' && event.target instanceof HTMLInputElement
|
||||
if (pane === 'model' && showSearch && event.target instanceof HTMLInputElement
|
||||
&& (event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey))) {
|
||||
if (event.key === 'Tab' && visibleModels.length === 0) return
|
||||
event.preventDefault()
|
||||
@@ -346,9 +366,9 @@ export function ModelSelect(
|
||||
}
|
||||
if (focused !== triggerRef.current) return
|
||||
event.preventDefault()
|
||||
if (pane === 'model') {
|
||||
if (pane === 'model' && showSearch) {
|
||||
setHighlightedIndex(null)
|
||||
menuRef.current?.querySelector('input')?.focus()
|
||||
searchRef.current?.focus()
|
||||
return
|
||||
}
|
||||
const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
|
||||
@@ -372,7 +392,7 @@ export function ModelSelect(
|
||||
const settleSelection = (result: Awaited<ReturnType<ModelSelectInjected['select']>>): void => {
|
||||
if (result === undefined) return
|
||||
if (result.ok) {
|
||||
if (rootRef.current !== null) close(true)
|
||||
if (rootRef.current !== null) closeAfterSelection()
|
||||
return
|
||||
}
|
||||
const { error } = result
|
||||
@@ -388,13 +408,14 @@ export function ModelSelect(
|
||||
const submit = (selection: ModelSelection): void => {
|
||||
lastActionRef.current = 'select'
|
||||
// Disabled option rows cannot retain focus while a selection is pending.
|
||||
setSelectionFocus(true)
|
||||
triggerRef.current?.focus()
|
||||
void select(selection).then(settleSelection)
|
||||
}
|
||||
|
||||
const choose = (selection: ModelSelection): void => {
|
||||
if (state.current?.provider === selection.provider && state.current.model === selection.model) {
|
||||
close(true)
|
||||
closeAfterSelection()
|
||||
return
|
||||
}
|
||||
submit(selection)
|
||||
@@ -403,7 +424,7 @@ export function ModelSelect(
|
||||
const chooseEffort = (effort: string | undefined): void => {
|
||||
if (state.current === null) return
|
||||
if (effectiveEffort === effort) {
|
||||
close(true)
|
||||
closeAfterSelection()
|
||||
return
|
||||
}
|
||||
const selection: ModelSelection = {
|
||||
@@ -456,6 +477,8 @@ export function ModelSelect(
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
title={triggerLabel}
|
||||
aria-busy={busy}
|
||||
data-selection-focus={selectionFocus ? '' : undefined}
|
||||
onBlur={() => { setSelectionFocus(false) }}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
@@ -505,8 +528,9 @@ export function ModelSelect(
|
||||
|
||||
{pane === 'model' && (
|
||||
<>
|
||||
<div className={css.searchRow}>
|
||||
{showSearch && <div className={css.searchRow}>
|
||||
<Input
|
||||
ref={searchRef}
|
||||
className={clsx(css.search, query !== '' && css.searchWithQuery)}
|
||||
type="text"
|
||||
role="searchbox"
|
||||
@@ -526,13 +550,13 @@ export function ModelSelect(
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
changeQuery('')
|
||||
menuRef.current?.querySelector('input')?.focus()
|
||||
searchRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<IconCloseFillRegular />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
{state.status === 'loading' && (
|
||||
<div className={css.status}>{t('status.loading')}</div>
|
||||
)}
|
||||
@@ -571,12 +595,16 @@ export function ModelSelect(
|
||||
role="menuitemradio"
|
||||
aria-checked={selected}
|
||||
id={`${id}-model-${index}`}
|
||||
tabIndex={-1}
|
||||
tabIndex={showSearch ? -1 : 0}
|
||||
onFocus={() => { setHighlightedIndex(index) }}
|
||||
data-highlighted={index === activeModelIndex ? '' : undefined}
|
||||
className={clsx(
|
||||
css.option, css.modelOption, selected && css.selected, index === activeModelIndex && css.optionActive,
|
||||
)}
|
||||
onMouseMove={busy || index === activeModelIndex ? undefined : () => { setHighlightedIndex(index) }}
|
||||
onMouseMove={busy || index === activeModelIndex ? undefined : () => {
|
||||
if (showSearch) setHighlightedIndex(index)
|
||||
else itemRefs.current[index]?.focus()
|
||||
}}
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
disabled={busy}
|
||||
|
||||
@@ -53,6 +53,16 @@ function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryStat
|
||||
}
|
||||
}
|
||||
|
||||
function modelGroups(count: number): ModelDirectoryState['groups'] {
|
||||
const group = state().groups[0]!
|
||||
return [{ ...group, models: [
|
||||
...group.models,
|
||||
...Array.from({ length: Math.max(0, count - 1) }, (_, index) => ({
|
||||
id: `model-${index + 2}`, name: `Model ${index + 2}`,
|
||||
})),
|
||||
].slice(0, count) }]
|
||||
}
|
||||
|
||||
const scrollIntoView = vi.fn()
|
||||
beforeEach(() => {
|
||||
scrollIntoView.mockClear()
|
||||
@@ -236,10 +246,8 @@ describe('ModelSelect reasoning effort', () => {
|
||||
// The selection failure does not render the in-menu load strip (no Retry).
|
||||
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
|
||||
fireEvent.keyDown(trigger, { key: 'Tab' })
|
||||
const search = screen.getByRole('searchbox')
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(search.getAttribute('aria-activedescendant'))
|
||||
.toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }).id)
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }))
|
||||
})
|
||||
|
||||
it('spins on the trigger and the chosen model row until the selection settles, across pane changes', async () => {
|
||||
@@ -487,13 +495,14 @@ describe('ModelSelect keyboard walk', () => {
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
expect(document.activeElement).toBe(screen.getByRole('searchbox'))
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
|
||||
const retry = screen.getByRole('button', { name: '重试' })
|
||||
expect(fireEvent.mouseDown(retry)).toBe(false)
|
||||
fireEvent.click(retry)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
expect(screen.getByRole('group', { name: '模型与推理等级' })).toBeTruthy()
|
||||
retry.focus()
|
||||
// A control that is not a row keeps the browser's traversal.
|
||||
expect(fireEvent.keyDown(retry, { key: 'Tab' })).toBe(true)
|
||||
@@ -505,12 +514,13 @@ describe('ModelSelect keyboard walk', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('focuses search while retaining the checked model', () => {
|
||||
it('focuses the checked model in a small catalog', () => {
|
||||
mountOpen()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
const rows = screen.getAllByRole('menuitemradio')
|
||||
expect(rows[0]!.getAttribute('aria-checked')).toBe('true')
|
||||
expect(document.activeElement).toBe(screen.getByRole('searchbox'))
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
expect(document.activeElement).toBe(rows[0])
|
||||
})
|
||||
|
||||
it('Escape returns to the root pane with the keyboard on the cell that drilled in', () => {
|
||||
@@ -536,7 +546,7 @@ describe('ModelSelect keyboard walk', () => {
|
||||
expect(document.activeElement).toBe(cells[0])
|
||||
})
|
||||
|
||||
it('focuses search when no model is checked', () => {
|
||||
it('focuses the first row when no model is checked in a small catalog', () => {
|
||||
// The session runs a model the catalog no longer lists: no row is checked.
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
@@ -550,27 +560,214 @@ describe('ModelSelect keyboard walk', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
const rows = screen.getAllByRole('menuitemradio')
|
||||
expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true)
|
||||
expect(document.activeElement).toBe(screen.getByRole('searchbox'))
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
expect(document.activeElement).toBe(rows[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelSelect catalog size', () => {
|
||||
it.each([0, 1, 4, 5])('shows search only above four models (%i models)', (count) => {
|
||||
const groups = modelGroups(count)
|
||||
const current = { provider: 'deepseek-official', model: count > 1 ? 'model-2' : 'deepseek-v4-flash' }
|
||||
render(<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups, current }))}
|
||||
load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
const rows = screen.queryAllByRole('menuitemradio')
|
||||
expect(rows).toHaveLength(count)
|
||||
if (count > 4) {
|
||||
const search = screen.getByRole('searchbox')
|
||||
expect(search).toBeInstanceOf(HTMLInputElement)
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(rows[1]!.id)
|
||||
expect(rows.every(row => row.tabIndex === -1)).toBe(true)
|
||||
} else {
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
expect(document.activeElement).toBe(count === 0 ? trigger : rows[count > 1 ? 1 : 0])
|
||||
expect(rows.every(row => row.tabIndex === 0)).toBe(true)
|
||||
}
|
||||
if (count === 0) expect(screen.getByRole('status').textContent).toBe(zh['empty.models'])
|
||||
})
|
||||
|
||||
it.each([0, 1, 4, 5])('opens an unselected %i-model catalog on search or its first row', (count) => {
|
||||
render(<ModelSelect locked={false} available
|
||||
directory={createSnapshotStore(state({ groups: modelGroups(count), current: null }))}
|
||||
load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
const trigger = screen.getByRole('button', { name: '请选择模型' })
|
||||
fireEvent.click(trigger)
|
||||
const rows = screen.queryAllByRole('menuitemradio')
|
||||
expect(rows).toHaveLength(count)
|
||||
expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true)
|
||||
expect(document.activeElement).toBe(count > 4 ? screen.getByRole('searchbox') : rows[0] ?? trigger)
|
||||
if (count <= 4) expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([true, false])('clears search and restores focus across 5 → 4 → 5 models (checked: %s)', (checked) => {
|
||||
const current = { provider: 'deepseek-official', model: checked ? 'model-3' : 'removed' }
|
||||
const directory = createSnapshotStore(state({ groups: modelGroups(5), current }))
|
||||
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
const search = screen.getByRole('searchbox')
|
||||
fireEvent.change(search, { target: { value: 'Model 5' } })
|
||||
expect(screen.getAllByRole('menuitemradio')).toHaveLength(1)
|
||||
expect(screen.getByRole('searchbox')).toBe(search)
|
||||
expect(document.activeElement).toBe(search)
|
||||
|
||||
act(() => { directory.set(state({ groups: modelGroups(4), current })) })
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull()
|
||||
const rows = screen.getAllByRole('menuitemradio')
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(document.activeElement).toBe(rows[checked ? 2 : 0])
|
||||
|
||||
act(() => { directory.set(state({ groups: modelGroups(5), current })) })
|
||||
const restored = screen.getByRole('searchbox')
|
||||
expect(restored.getAttribute('value')).toBe('')
|
||||
expect(document.activeElement).toBe(restored)
|
||||
expect(screen.getAllByRole('menuitemradio')).toHaveLength(5)
|
||||
expect(restored.getAttribute('aria-activedescendant'))
|
||||
.toBe(screen.getAllByRole('menuitemradio')[checked ? 2 : 0]!.id)
|
||||
})
|
||||
|
||||
it('moves actual row focus with arrows, leaves Enter native, and selects with Tab in a small catalog', async () => {
|
||||
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
|
||||
render(<ModelSelect locked={false} available
|
||||
directory={createSnapshotStore(state({ groups: modelGroups(4), current: { provider: 'deepseek-official', model: 'model-2' } }))}
|
||||
load={vi.fn()} select={select} t={t} />)
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
const rows = screen.getAllByRole('menuitemradio')
|
||||
expect(document.activeElement).toBe(rows[1])
|
||||
expect(fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })).toBe(false)
|
||||
expect(document.activeElement).toBe(rows[2])
|
||||
fireEvent.keyDown(rows[2]!, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(rows[3])
|
||||
fireEvent.keyDown(rows[3]!, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(rows[0])
|
||||
fireEvent.keyDown(rows[0]!, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(rows[3])
|
||||
fireEvent.keyDown(rows[3]!, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(rows[2])
|
||||
expect(rows[2]!.hasAttribute('data-highlighted')).toBe(true)
|
||||
expect(fireEvent.keyDown(rows[2]!, { key: 'Enter' })).toBe(true)
|
||||
expect(fireEvent.keyDown(rows[2]!, { key: 'ArrowLeft' })).toBe(true)
|
||||
expect(document.activeElement).toBe(rows[2])
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
expect(fireEvent.keyDown(rows[2]!, { key: 'Tab' })).toBe(false)
|
||||
expect(select).toHaveBeenCalledWith({ provider: 'deepseek-official', model: 'model-3' })
|
||||
await waitFor(() => { expect(document.activeElement).toBe(trigger) })
|
||||
expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull()
|
||||
expect(trigger.hasAttribute('data-selection-focus')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps Tab selection available after hovering a small-catalog row', async () => {
|
||||
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
|
||||
render(<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups: modelGroups(4) }))}
|
||||
load={vi.fn()} select={select} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
const row = screen.getByRole('menuitemradio', { name: 'Model 3' })
|
||||
fireEvent.mouseMove(row)
|
||||
expect(document.activeElement).toBe(row)
|
||||
expect(row.hasAttribute('data-highlighted')).toBe(true)
|
||||
expect(fireEvent.keyDown(row, { key: 'Tab' })).toBe(false)
|
||||
expect(select).toHaveBeenCalledWith({ provider: 'deepseek-official', model: 'model-3' })
|
||||
await waitFor(() => { expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() })
|
||||
})
|
||||
|
||||
it.each(['Escape', 'Tab'])('leaves a small model pane with %s and returns to its root cell', (key) => {
|
||||
const select = vi.fn()
|
||||
render(<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups: modelGroups(4) }))}
|
||||
load={vi.fn()} select={select} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
const row = screen.getAllByRole('menuitemradio')[0]!
|
||||
expect(fireEvent.keyDown(row, { key, shiftKey: key === 'Tab' })).toBe(false)
|
||||
const cell = screen.getByRole('menuitem', { name: /^模型/ })
|
||||
expect(document.activeElement).toBe(cell)
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(cell, { key, shiftKey: key === 'Tab' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelSelect search', () => {
|
||||
it('clears search when reopening an unselected large catalog', () => {
|
||||
render(<ModelSelect locked={false} available
|
||||
directory={createSnapshotStore(state({ groups: modelGroups(5), current: null }))}
|
||||
load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
const trigger = screen.getByRole('button', { name: '请选择模型' })
|
||||
fireEvent.click(trigger)
|
||||
const search = screen.getByRole('searchbox')
|
||||
expect(document.activeElement).toBe(search)
|
||||
fireEvent.change(search, { target: { value: 'zzzz' } })
|
||||
fireEvent.keyDown(search, { key: 'Escape' })
|
||||
expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('searchbox').getAttribute('value')).toBe('')
|
||||
expect(document.activeElement).toBe(screen.getByRole('searchbox'))
|
||||
expect(screen.getAllByRole('menuitemradio')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('hides empty provider headings and announces an empty catalog', () => {
|
||||
const directory = createSnapshotStore(state({ groups: [
|
||||
...state().groups, { id: 'empty', name: 'Empty Provider', models: [] },
|
||||
] }))
|
||||
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
expect(screen.queryByRole('group', { name: 'Empty Provider' })).toBeNull()
|
||||
act(() => { directory.set(state({ groups: [] })) })
|
||||
expect(screen.getByRole('status').textContent).toBe(zh['empty.models'])
|
||||
expect(screen.getByRole('status').closest('[role="menu"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['Enter', 'Tab'])('returns focus silently after %s accepts the current model, without suppressing later focus', async (key) => {
|
||||
const select = vi.fn()
|
||||
render(<>
|
||||
<button type="button">Outside</button>
|
||||
<ModelSelect locked={false} available directory={createSnapshotStore(state({ groups: modelGroups(5) }))}
|
||||
load={vi.fn()} select={select} t={t} />
|
||||
</>)
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
fireEvent.keyDown(screen.getByRole('searchbox'), { key })
|
||||
await waitFor(() => { expect(document.activeElement).toBe(trigger) })
|
||||
expect(trigger.hasAttribute('data-selection-focus')).toBe(true)
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
act(() => { screen.getByRole('button', { name: 'Outside' }).focus() })
|
||||
expect(trigger.hasAttribute('data-selection-focus')).toBe(false)
|
||||
act(() => { trigger.focus() })
|
||||
expect(trigger.hasAttribute('data-selection-focus')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['Enter', 'Tab'])('keeps typing focus while arrows wrap across groups and %s accepts the highlight', async (key) => {
|
||||
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
|
||||
const directory = createSnapshotStore(state({
|
||||
current: { provider: 'deepseek-official', model: 'beta' },
|
||||
groups: [
|
||||
{ id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }] },
|
||||
{ id: 'other', name: 'Other', models: [{ id: 'gamma', name: 'Gamma' }] },
|
||||
{ id: 'other', name: 'Other', models: [
|
||||
{ id: 'delta', name: 'Delta' }, { id: 'epsilon', name: 'Epsilon' }, { id: 'gamma', name: 'Gamma' },
|
||||
] },
|
||||
],
|
||||
}))
|
||||
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
const search = screen.getByRole('searchbox')
|
||||
const [alpha, beta, gamma] = screen.getAllByRole('menuitemradio')
|
||||
expect(search).toBeInstanceOf(HTMLInputElement)
|
||||
const [alpha, beta, delta, epsilon, gamma] = screen.getAllByRole('menuitemradio')
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(beta!.id)
|
||||
fireEvent.keyDown(search, { key: 'ArrowDown' })
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(delta!.id)
|
||||
fireEvent.keyDown(search, { key: 'ArrowDown' })
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(epsilon!.id)
|
||||
fireEvent.keyDown(search, { key: 'ArrowDown' })
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(gamma!.id)
|
||||
expect(document.activeElement).toBe(search)
|
||||
fireEvent.keyDown(search, { key: 'ArrowDown' })
|
||||
@@ -594,17 +791,20 @@ describe('ModelSelect search', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
const restored = screen.getAllByRole('menuitemradio')
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(restored[0]!.id)
|
||||
fireEvent.mouseMove(restored[2]!)
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(restored[2]!.id)
|
||||
fireEvent.mouseMove(restored[4]!)
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe(restored[4]!.id)
|
||||
expect(document.activeElement).toBe(search)
|
||||
fireEvent.keyDown(search, { key })
|
||||
expect(select).toHaveBeenCalledWith({ provider: 'other', model: 'gamma' })
|
||||
await waitFor(() => { expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() })
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
await waitFor(() => { expect(document.activeElement).toBe(trigger) })
|
||||
expect(trigger.hasAttribute('data-selection-focus')).toBe(true)
|
||||
})
|
||||
|
||||
it('filters model names fuzzily, hides empty groups, clears on reopening, and selects a result', async () => {
|
||||
const directory = createSnapshotStore(state({ groups: [
|
||||
...state().groups,
|
||||
...modelGroups(4),
|
||||
{ id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] },
|
||||
] }))
|
||||
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
|
||||
@@ -619,6 +819,8 @@ describe('ModelSelect search', () => {
|
||||
expect(document.activeElement).toBe(search)
|
||||
fireEvent.change(search, { target: { value: ' GMFL ' } })
|
||||
expect(screen.getAllByRole('menuitemradio').map(row => row.textContent)).toEqual(['Gemini Flash'])
|
||||
expect(screen.getByRole('searchbox')).toBe(search)
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(screen.queryByRole('group', { name: 'DeepSeek' })).toBeNull()
|
||||
expect(trigger.textContent).toContain('DeepSeek-V4-Flash')
|
||||
fireEvent.change(search, { target: { value: 'zzzz' } })
|
||||
@@ -632,7 +834,7 @@ describe('ModelSelect search', () => {
|
||||
expect(search.getAttribute('value')).toBe('')
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull()
|
||||
expect(screen.getAllByRole('menuitemradio')).toHaveLength(2)
|
||||
expect(screen.getAllByRole('menuitemradio')).toHaveLength(5)
|
||||
fireEvent.change(search, { target: { value: 'gmfl' } })
|
||||
fireEvent.keyDown(search, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
@@ -699,10 +901,15 @@ it('shows the unselected model control with the inherited effort', async () => {
|
||||
expect(trigger.textContent).toContain('High')
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull()
|
||||
const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })
|
||||
expect(document.activeElement).toBe(model)
|
||||
fireEvent.keyDown(model, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
const row = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })
|
||||
expect(document.activeElement).toBe(row)
|
||||
fireEvent.keyDown(row, { key: 'Escape' })
|
||||
expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }))
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: b521f773eff5c728
|
||||
zh: 7b56c5b4777a0b00
|
||||
en: c4d22d4642ec6fe0
|
||||
zh: 27c4dcae204d1f8f
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: b4cd33720a6cf6b8
|
||||
zh: 8db75bf633789c6d
|
||||
|
||||
@@ -44,7 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `Switch` | Two-state toggle, 36×20. The off-state thumb reads `--dsw-alias-switch-thumb` so it stays light in both themes, and the on-state thumb contrasts with the brand track. Disabled controls use half opacity. `label` is required, so the control cannot ship unnamed. |
|
||||
| `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. |
|
||||
| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
|
||||
| `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. |
|
||||
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
||||
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. |
|
||||
|
||||
@@ -44,7 +44,7 @@ kind: "package-library"
|
||||
| `Switch` | 36×20 的双态开关。关闭态滑块读取 `--dsw-alias-switch-thumb`,在两种主题下均保持亮色;开启态滑块与品牌色轨道形成对比。禁用控件使用半透明样式。`label` 必填,控件不可能在没有名称的情况下发布。 |
|
||||
| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`<id>-<value>`)及其控制的面板 id(`<id>-<value>-panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 |
|
||||
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
|
||||
| `Input` | 单行文本输入,用于搜索框与行内表单。 |
|
||||
| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 |
|
||||
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
||||
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
||||
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
// Input: single-line text input atom (search boxes, inline forms). Composer
|
||||
// textareas are NOT this atom — they live with the conversation package.
|
||||
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react'
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import css from './Input.module.css'
|
||||
|
||||
/**
|
||||
* Render a text input with an optional leading icon.
|
||||
* @param props.icon - optional 16px leading icon node.
|
||||
* @param ref - the native input, cleared when it unmounts.
|
||||
* @returns wrapper span containing the native input; input attributes pass through.
|
||||
*/
|
||||
export function Input({ icon, className, ...rest }: {
|
||||
export const Input = forwardRef<HTMLInputElement, {
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
} & InputHTMLAttributes<HTMLInputElement>) {
|
||||
} & InputHTMLAttributes<HTMLInputElement>>(function Input({ icon, className, ...rest }, ref) {
|
||||
return (
|
||||
<span className={clsx(css.wrap, className)}>
|
||||
{icon != null && <span className={css.icon}>{icon}</span>}
|
||||
<input className={css.input} {...rest} />
|
||||
<input ref={ref} className={css.input} {...rest} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -59,6 +59,16 @@ describe('Pill', () => {
|
||||
})
|
||||
|
||||
describe('Input', () => {
|
||||
it('exposes its native input for focus and releases the ref on unmount', () => {
|
||||
const ref = createRef<HTMLInputElement>()
|
||||
const { unmount } = render(<Input ref={ref} aria-label="Search" />)
|
||||
expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Search' }))
|
||||
ref.current?.focus()
|
||||
expect(document.activeElement).toBe(ref.current)
|
||||
unmount()
|
||||
expect(ref.current).toBeNull()
|
||||
})
|
||||
|
||||
it('forwards value/onChange and renders the leading icon', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<Input icon={<svg data-testid="ic" />} value="q" onChange={onChange} placeholder="search" />)
|
||||
|
||||
Reference in new issue
Block a user