refactor(client): simplify and cache mobile device detection

This commit is contained in:
Stevring committed 2026-10-22 10:18:39 +08:00
1 parent 0727eac568
commit dc83ed11bc
12 files changed
+95 -20

No files matched your search

@@ -10,6 +10,7 @@
* retry button, the highlighted row scrolls into view, and the card height
* clamps to the space above the composer.
*/
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SelectOption } from '../src/client/contract.ts'
@@ -157,6 +158,10 @@ describe('PopupSelectView', () => {
else Object.defineProperty(navigator, name, descriptor)
})
}
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const { popup, search } = await mountOpen()
expect(document.activeElement === search).toBe(automatic)
act(() => { search.focus() })
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
/** Composer selection survives focus transfers and background editor reconciliation. */
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import { expect, it, onTestFinished, vi } from 'vitest'
import { $getRoot, $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND, UNDO_COMMAND } from 'lexical'
import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts'
@@ -140,9 +141,13 @@ it('restores a pending selection when blur and explicit focus share one update b
expect(reveal).toHaveBeenCalledOnce()
})
it('hands mobile submission focus to the composer without stealing focus from another control', () => {
it('hands mobile submission focus to the composer without stealing focus from another control', async () => {
const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1')
onTestFinished(() => { userAgent.mockRestore() })
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const f = fixture()
const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card })
onTestFinished(dispose)
@@ -157,9 +162,13 @@ it('hands mobile submission focus to the composer without stealing focus from an
expect(document.activeElement).toBe(f.outside)
})
it('retains mobile focus for a newer draft suffix and removes dismissal with the view', () => {
it('retains mobile focus for a newer draft suffix and removes dismissal with the view', async () => {
const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1')
onTestFinished(() => { userAgent.mockRestore() })
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const f = fixture()
const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card })
onTestFinished(dispose)
@@ -8,6 +8,7 @@
// root listener routes them through the keymap commands); draft writes drive
// the shell (jsdom's beforeinput lacks the ranges Lexical needs).
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import './control-row-dom.ts'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
@@ -1431,6 +1432,10 @@ describe('running and lock semantics', () => {
else Object.defineProperty(navigator, name, descriptor)
})
}
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const outside = document.createElement('input')
document.body.append(outside)
onTestFinished(() => { outside.remove() })
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
@@ -471,6 +472,10 @@ describe('ModelSelect keyboard walk', () => {
else Object.defineProperty(navigator, name, descriptor)
})
}
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const select = vi.fn()
render(<ModelSelect renderSlot={renderLabel} locked={false} available
directory={createSnapshotStore(state({ groups: modelGroups(modelCount) }))}
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
en: 5875d977b12a64f4
zh: a89693f28c608558
en: cffd6fbc15f66037
zh: 914515e7d1977c77
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: adaaabb712de1a3d
zh: 45d6286e39880274
+1 -1
View File
@@ -27,7 +27,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
`isMobileDevice()` identifies phones and tablets for behavior such as automatic text-input focus and keyboard dismissal. It returns true for Bowser's UA classifications `mobile` and `tablet`, and for iPads reporting `MacIntel` with more than one touch point. It returns false for other devices, including Windows touchscreen computers, regardless of window width. The composer and command popups suppress automatic text-input focus on identified mobile devices; user-requested focus remains available. Browser modes or custom UA strings that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers.
`isMobileDevice()` identifies iOS and Android phones and tablets for automatic text-input focus and keyboard dismissal. It recognizes `Android`, `iPhone`, `iPad`, or `iPod` in the UA, and iPads reporting `MacIntel` with more than one touch point. Other devices, including Windows touchscreen computers, return false regardless of window width. The function caches its first result for all callers in the page; component remounts reuse that result, and reloading the page reads the browser environment again. Identified mobile devices require explicit text-input focus. Browser modes or custom UAs that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers.
Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
+1 -1
View File
@@ -27,7 +27,7 @@ kind: "package-library"
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
`isMobileDevice()` 识别手机和平板,供自动聚焦和收起键盘等行为使用。对于 Bowser 从 UA 识别为 `mobile` 或 `tablet` 的设备,以及报告 `MacIntel` 且触摸点数大于一的 iPad,该函数返回 true。对于其他设备(包括 Windows 触屏电脑),该函数返回 false,不受窗口宽度影响。composer 和命令弹窗在识别出的移动设备上禁止自动聚焦;用户主动请求的聚焦仍然可用。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
`isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
+1 -2
View File
@@ -48,8 +48,7 @@
"mdast-util-math": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-util-sanitize-uri": "^2.0.1",
"simple-icons": "16.31.0",
"bowser": "^2.14.1"
"simple-icons": "16.31.0"
},
"files": [
"lib/index.js",
+10 -7
View File
@@ -1,17 +1,20 @@
/** Mobile-device detection and automatic focus presentation. */
import bowser from 'bowser'
const releases = new WeakMap<HTMLElement, () => void>()
const navigationKeys = new Set(['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'])
let mobileDevice: boolean | undefined
/**
* Identify phones and tablets from the current browser's device information.
* Bowser identifies phones and tablets from the UA; MacIntel with multiple touch points identifies desktop-mode iPads.
* @returns true for identified phones and tablets; false for other devices, including unrecognized UAs.
* Identify iOS and Android phones and tablets from the current browser's device information.
* MacIntel with multiple touch points identifies desktop-mode iPads.
* The first result is shared by all callers until the page reloads.
* @returns true for recognized mobile devices; false for other devices, including unrecognized UAs.
*/
export function isMobileDevice(): boolean {
const platformType = bowser.parse(navigator.userAgent).platform.type
const desktopIPad = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1
return platformType === 'mobile' || platformType === 'tablet' || desktopIPad
if (mobileDevice !== undefined) return mobileDevice
const { userAgent, platform, maxTouchPoints } = navigator
mobileDevice = /Android|iPhone|iPad|iPod/i.test(userAgent)
|| (platform === 'MacIntel' && maxTouchPoints > 1)
return mobileDevice
}
/**
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { createRef, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { Button, ConnectionIndicator, Input, Menu, MenuItemButton, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
import { focusWithoutRing } from '../src/focus.ts'
@@ -9,6 +9,53 @@ import buttonCss from '../src/Button.module.css'
afterEach(cleanup)
describe('isMobileDevice', () => {
beforeEach(() => { vi.resetModules() })
it.each([
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X)', platform: 'iPhone', maxTouchPoints: 5, mobile: true },
{ name: 'iPad', userAgent: 'Mozilla/5.0 (iPad; CPU OS 18_6 like Mac OS X)', platform: 'iPad', maxTouchPoints: 5, mobile: true },
{ name: 'iPod', userAgent: 'Mozilla/5.0 (iPod; U; CPU like Mac OS X; en)', platform: 'iPod', maxTouchPoints: 5, mobile: true },
{ name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) Mobile', platform: 'Linux armv8l', maxTouchPoints: 0, mobile: true },
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K)', platform: 'Linux armv8l', maxTouchPoints: 5, mobile: true },
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', platform: 'MacIntel', maxTouchPoints: 5, mobile: true },
{ name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', platform: 'MacIntel', maxTouchPoints: 0, mobile: false },
{ name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', platform: 'Win32', maxTouchPoints: 5, mobile: false },
{ name: 'native networking UA', userAgent: 'DeepSeek%20Chat/0 CFNetwork/3860.400.51 Darwin/25.3.0', platform: '', maxTouchPoints: 0, mobile: false },
{ name: 'unknown UA', userAgent: 'Unrecognized/1.0', platform: '', maxTouchPoints: 0, mobile: false },
])('classifies $name', async ({ userAgent, platform, maxTouchPoints, mobile }) => {
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
Object.defineProperty(navigator, name, { configurable: true, value })
onTestFinished(() => {
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
else Object.defineProperty(navigator, name, descriptor)
})
}
const { isMobileDevice } = await import('../src/focus.ts')
expect(isMobileDevice()).toBe(mobile)
})
it.each([true, false])('shares a cached %s result between callers until the module reloads', async (mobile) => {
const mobileUA = 'Mozilla/5.0 (iPhone)'
const desktopUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
const ua = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(mobile ? mobileUA : desktopUA)
onTestFinished(() => { ua.mockRestore() })
const first = await import('../src/focus.ts')
expect(first.isMobileDevice()).toBe(mobile)
ua.mockReturnValue(mobile ? desktopUA : mobileUA)
const other = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
expect(other.isMobileDevice()).toBe(mobile)
expect(ua).toHaveBeenCalledTimes(1)
vi.resetModules()
const reloaded = await import('../src/focus.ts')
expect(reloaded.isMobileDevice()).toBe(!mobile)
expect(ua).toHaveBeenCalledTimes(2)
})
})
it('retains automatic focus styling while typing and restores it for keyboard navigation', () => {
render(<button>Automatic destination</button>)
const button = screen.getByRole('button')
@@ -4,6 +4,7 @@
* handover a portaled list performs once it is placed, the row walk, and the
* dismissal and focus-return paths the task manager's four menus rely on.
*/
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import { useState } from 'react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
@@ -146,7 +147,7 @@ describe('TaskMenu', () => {
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5 },
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5 },
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5 },
])('leaves header search focus to the user on $name', ({ userAgent, platform, maxTouchPoints }) => {
])('leaves header search focus to the user on $name', async ({ userAgent, platform, maxTouchPoints }) => {
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
Object.defineProperty(navigator, name, { configurable: true, value })
@@ -155,6 +156,10 @@ describe('TaskMenu', () => {
else Object.defineProperty(navigator, name, descriptor)
})
}
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const menu = (open: boolean) => <TaskMenu
open={open}
portal
-3
View File
@@ -4882,9 +4882,6 @@ importers:
anser:
specifier: ^2.3.5
version: 2.3.5
bowser:
specifier: ^2.14.1
version: 2.14.1
clsx:
specifier: ^2.0.0
version: 2.1.1