refactor(ui-primitives): share document zoom controls
Move the floating zoom controls from the document preview into ui-primitives with the shared scale limits, so every document renderer offers the same controls. Document preview renders them through its zoom viewport with fit width as the optional fitting action.
This commit is contained in:
1 parent
4349b4e5f2
commit
f70a511cca
10 files changed
+111
-36
No files matched your search
@@ -18,8 +18,8 @@
|
||||
en: d2f5701274be5110
|
||||
zh: d60e2617136a6f26
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 9a278223fc7a2910
|
||||
zh: f086530927d74dee
|
||||
en: 0e5a5bd929dca916
|
||||
zh: bf123db2eac3dd97
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: fff68b60f4abdc7e
|
||||
zh: 5cf5952ec78f7f91
|
||||
|
||||
@@ -46,6 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `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. |
|
||||
| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, optional fitting, fixed presets, and visibility/activity callbacks. Renderers share the published scale limits. |
|
||||
| `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`. |
|
||||
|
||||
@@ -46,6 +46,7 @@ kind: "package-library"
|
||||
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
|
||||
| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 |
|
||||
| `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 |
|
||||
| `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、可选适配操作、固定预设,以及可见性和交互状态回调。渲染器共用导出的缩放范围。 |
|
||||
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
||||
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
|
||||
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
||||
|
||||
File renamed without changes.
+24
-17
@@ -1,10 +1,14 @@
|
||||
/** Floating controls shared by every zoomable document renderer. */
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState, type ReactNode } from 'react'
|
||||
import { Button, IconChevronDownOutlineRegular, IconPlusOutlineRegular, Menu, Tooltip,
|
||||
type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { MAX_FIXED_ZOOM, MIN_FIXED_ZOOM, ZOOM_OPTIONS, ZOOM_STEP, type ZoomLabels } from './types.ts'
|
||||
import { Button } from './Button.tsx'
|
||||
import { IconChevronDownOutlineRegular, IconPlusOutlineRegular } from './icons/index.tsx'
|
||||
import { Menu, type MenuEntry } from './Menu.tsx'
|
||||
import { Tooltip } from './Tooltip.tsx'
|
||||
import { DOCUMENT_ZOOM, type DocumentZoomLabels } from './document-zoom.ts'
|
||||
import css from './ZoomControls.module.css'
|
||||
|
||||
const { minimum: MIN_FIXED_ZOOM, maximum: MAX_FIXED_ZOOM, step: ZOOM_STEP, presets: ZOOM_OPTIONS } = DOCUMENT_ZOOM
|
||||
|
||||
function steppedZoom(zoom: number, direction: -1 | 1): number {
|
||||
const step = direction === -1 ? Math.ceil(zoom / ZOOM_STEP) - 1 : Math.floor(zoom / ZOOM_STEP) + 1
|
||||
return Math.min(MAX_FIXED_ZOOM, Math.max(MIN_FIXED_ZOOM, step * ZOOM_STEP))
|
||||
@@ -17,37 +21,40 @@ export interface ZoomControlsHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* Present fit-width, fixed presets, and incremental controls.
|
||||
* Present optional fitting, fixed presets, and incremental controls.
|
||||
* @param props - current resolved zoom, preference mode, labels, and callbacks.
|
||||
* @returns a localized zoom toolbar.
|
||||
*/
|
||||
export const ZoomControls = forwardRef<ZoomControlsHandle, {
|
||||
readonly zoom: number
|
||||
readonly fitWidth: boolean
|
||||
readonly labels: ZoomLabels
|
||||
readonly fit?: { readonly label: string; readonly selected: boolean; readonly onSelect: () => void } | undefined
|
||||
readonly className?: string | undefined
|
||||
readonly labels: DocumentZoomLabels
|
||||
readonly visible: boolean
|
||||
readonly onZoom: (zoom: number) => void
|
||||
readonly onFitWidth: () => void
|
||||
readonly onActiveChange: (active: boolean) => void
|
||||
}>(function ZoomControls({ zoom, fitWidth, labels, visible, onZoom, onFitWidth, onActiveChange }, ref): ReactNode {
|
||||
}>(function ZoomControls({
|
||||
zoom, fit, className, labels, visible, onZoom, onActiveChange,
|
||||
}, ref): ReactNode {
|
||||
const [displayZoom, setDisplayZoom] = useState(zoom)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
useEffect(() => { setDisplayZoom(zoom) }, [zoom])
|
||||
useImperativeHandle(ref, () => ({ showZoom: setDisplayZoom }), [])
|
||||
useEffect(() => { onActiveChange(open || hovered || focused) }, [focused, hovered, onActiveChange, open])
|
||||
const items: readonly MenuEntry[] = [
|
||||
{ id: 'fit-width', label: labels.fitWidth },
|
||||
{ type: 'separator', id: 'fit-scale' },
|
||||
...(fit === undefined ? [] : [{ id: 'fit', label: fit.label }, { type: 'separator' as const, id: 'fit-scale' }]),
|
||||
...ZOOM_OPTIONS.map(value => ({ id: String(value), label: labels.value(value * 100) })),
|
||||
]
|
||||
const selectedId = fitWidth ? 'fit-width'
|
||||
: ZOOM_OPTIONS.includes(displayZoom as typeof ZOOM_OPTIONS[number]) ? String(displayZoom) : undefined
|
||||
return <div className={`${css.panel} ${visible ? css.visible : ''}`} role="toolbar" aria-label={labels.controls}
|
||||
const selectedId = fit?.selected === true ? 'fit'
|
||||
: ZOOM_OPTIONS.some(value => value === displayZoom) ? String(displayZoom) : undefined
|
||||
return <div className={`${css.panel} ${visible ? css.visible : ''} ${className ?? ''}`} role="toolbar" aria-label={labels.controls}
|
||||
data-document-zoom-controls data-document-zoom-visible={visible || undefined}
|
||||
onPointerEnter={(event) => { if (event.pointerType !== 'touch') setHovered(true) }}
|
||||
onPointerLeave={() => { setHovered(false) }}
|
||||
onFocusCapture={() => { setFocused(true) }} onBlurCapture={() => { setFocused(false) }}>
|
||||
onPointerEnter={(event) => { if (event.pointerType !== 'touch') setHovered(true) }} onPointerLeave={() => { setHovered(false) }}
|
||||
onFocusCapture={() => { setFocused(true) }} onBlurCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false)
|
||||
}}>
|
||||
<Tooltip label={labels.out} side="top">
|
||||
<span className={css.controlAnchor}><Button size="sm" className={css.zoomButton} aria-label={labels.out}
|
||||
disabled={displayZoom <= MIN_FIXED_ZOOM} onMouseDown={(event) => { event.preventDefault() }}
|
||||
@@ -65,7 +72,7 @@ export const ZoomControls = forwardRef<ZoomControlsHandle, {
|
||||
</Button>}
|
||||
items={items} selectedId={selectedId} onClose={() => { setOpen(false) }} onSelect={(selected) => {
|
||||
setOpen(false)
|
||||
if (selected === 'fit-width') onFitWidth()
|
||||
if (selected === 'fit') fit?.onSelect()
|
||||
else onZoom(Number(selected))
|
||||
}} />
|
||||
<Tooltip label={labels.into} side="top">
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Shared fixed document scales used by preview surfaces and their controls. */
|
||||
/** Fixed scale limits, incremental step, and menu choices. */
|
||||
export const DOCUMENT_ZOOM = { minimum: 0.25, maximum: 4, step: 0.25, presets: [0.25, 0.5, 1, 1.5, 2] } as const
|
||||
|
||||
/** Localized labels supplied by the document renderer. */
|
||||
export interface DocumentZoomLabels {
|
||||
readonly controls: string
|
||||
readonly menu: string
|
||||
readonly out: string
|
||||
readonly into: string
|
||||
readonly value: (percent: number) => string
|
||||
}
|
||||
@@ -15,6 +15,10 @@ export { useRowDragPreview } from './useRowDragPreview.ts'
|
||||
export { TextShimmer } from './TextShimmer.tsx'
|
||||
export type { TextShimmerProps } from './TextShimmer.tsx'
|
||||
export { Button } from './Button.tsx'
|
||||
export { ZoomControls } from './ZoomControls.tsx'
|
||||
export type { ZoomControlsHandle } from './ZoomControls.tsx'
|
||||
export { DOCUMENT_ZOOM } from './document-zoom.ts'
|
||||
export type { DocumentZoomLabels } from './document-zoom.ts'
|
||||
export type { ButtonVariant } from './Button.tsx'
|
||||
export { Pill } from './Pill.tsx'
|
||||
export { SegmentedTabs } from './SegmentedTabs.tsx'
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Document controls share fitting, preset selection, gesture display, and focus-safe activation. */
|
||||
import { createRef } from 'react'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ZoomControls, type ZoomControlsHandle } from '../src/ZoomControls.tsx'
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
const labels = { controls: 'Document zoom', menu: 'Choose zoom', into: 'Zoom in', out: 'Zoom out', value: (percent: number) => `${String(percent)}%` }
|
||||
|
||||
test('shows owner-specific fitting, changes scale, and follows gesture updates without a document rerender', () => {
|
||||
const zoom = vi.fn(), fit = vi.fn(), active = vi.fn(), ref = createRef<ZoomControlsHandle>()
|
||||
const props = { zoom: 1.13, visible: true, labels, onZoom: zoom, onActiveChange: active,
|
||||
fit: { label: 'Fit page', selected: true, onSelect: fit } }
|
||||
const view = render(<ZoomControls {...props} ref={ref} className="owner-position" />)
|
||||
expect(view.container.querySelector('[role="toolbar"]')?.classList.contains('owner-position')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fit page' }))
|
||||
expect(fit).toHaveBeenCalledOnce()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '150%' }))
|
||||
expect(zoom).toHaveBeenLastCalledWith(1.5)
|
||||
act(() => { ref.current!.showZoom(1.13) })
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('113%')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
|
||||
expect(zoom).toHaveBeenLastCalledWith(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
expect(zoom).toHaveBeenLastCalledWith(1.25)
|
||||
view.rerender(<ZoomControls {...props} zoom={4} ref={ref} />)
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' }).hasAttribute('disabled')).toBe(true)
|
||||
view.rerender(<ZoomControls {...props} zoom={0.25} ref={ref} />)
|
||||
expect(screen.getByRole('button', { name: 'Zoom out' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
test('omits fitting for spreadsheets and keeps the toolbar active during pointer, focus, and menu interaction', () => {
|
||||
const active = vi.fn(), zoom = vi.fn()
|
||||
const view = render(<ZoomControls zoom={1} visible={false} labels={labels} onZoom={zoom} onActiveChange={active} />)
|
||||
const panel = screen.getByRole('toolbar'), button = screen.getByRole('button', { name: 'Choose zoom' })
|
||||
expect(panel.hasAttribute('data-document-zoom-visible')).toBe(false)
|
||||
fireEvent.pointerEnter(panel)
|
||||
expect(active).toHaveBeenLastCalledWith(true)
|
||||
fireEvent.pointerLeave(panel)
|
||||
expect(active).toHaveBeenLastCalledWith(false)
|
||||
fireEvent.focus(button)
|
||||
expect(active).toHaveBeenLastCalledWith(true)
|
||||
fireEvent.blur(button, { relatedTarget: screen.getByRole('button', { name: 'Zoom in' }) })
|
||||
expect(active).toHaveBeenLastCalledWith(true)
|
||||
fireEvent.blur(button, { relatedTarget: document.body })
|
||||
expect(active).toHaveBeenLastCalledWith(false)
|
||||
expect(fireEvent.mouseDown(button)).toBe(false)
|
||||
expect(fireEvent.mouseDown(screen.getByRole('button', { name: 'Zoom in' }))).toBe(false)
|
||||
expect(fireEvent.mouseDown(screen.getByRole('button', { name: 'Zoom out' }))).toBe(false)
|
||||
fireEvent.click(button)
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['25%', '50%', '100%', '150%', '200%'])
|
||||
expect(active).toHaveBeenLastCalledWith(true)
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
view.unmount()
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Shared zoom viewport, gesture handling, anchoring, and fit-width measurement. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode, type RefCallback } from 'react'
|
||||
import { ZoomControls, type ZoomControlsHandle } from './ZoomControls.tsx'
|
||||
import { ZoomControls, type ZoomControlsHandle } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { FIT_WIDTH, MAX_FIXED_ZOOM, MIN_FIXED_ZOOM, type ZoomLabels, type ZoomPreference } from './types.ts'
|
||||
import css from './ZoomViewport.module.css'
|
||||
|
||||
@@ -206,8 +206,8 @@ export function ZoomViewport(props: ZoomViewportProps): ReactNode {
|
||||
<div ref={attachScrollport} className={css.scrollport} data-document-zoom-scrollport>{props.children}</div>
|
||||
<div className={css.revealZone} data-document-zoom-reveal-zone aria-hidden="true" />
|
||||
{props.intrinsicWidth !== undefined && <ZoomControls ref={controls} zoom={zoomRef.current}
|
||||
fitWidth={modeRef.current === 'fit-width'} labels={props.labels} visible={controlsVisible}
|
||||
onZoom={setZoom} onFitWidth={fitWidth} onActiveChange={handleControlsActive} />}
|
||||
fit={{ label: props.labels.fitWidth, selected: modeRef.current === 'fit-width', onSelect: fitWidth }}
|
||||
labels={props.labels} visible={controlsVisible} onZoom={setZoom} onActiveChange={handleControlsActive} />}
|
||||
</section>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DOCUMENT_ZOOM, type DocumentZoomLabels } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
/** Automatic fit or an explicit actual-size scale selected by the reader. */
|
||||
export type ZoomPreference =
|
||||
| { readonly kind: 'fit-width' }
|
||||
@@ -7,23 +8,12 @@ export type ZoomPreference =
|
||||
export const FIT_WIDTH: ZoomPreference = { kind: 'fit-width' }
|
||||
|
||||
/** Minimum scale retained by a fixed zoom preference. */
|
||||
export const MIN_FIXED_ZOOM = 0.25
|
||||
export const MIN_FIXED_ZOOM = DOCUMENT_ZOOM.minimum
|
||||
|
||||
/** Maximum scale retained by a fixed zoom preference. */
|
||||
export const MAX_FIXED_ZOOM = 4
|
||||
|
||||
/** Scale interval used by the incremental controls. */
|
||||
export const ZOOM_STEP = 0.25
|
||||
|
||||
/** Fixed scale choices shown in the zoom menu. */
|
||||
export const ZOOM_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const
|
||||
export const MAX_FIXED_ZOOM = DOCUMENT_ZOOM.maximum
|
||||
|
||||
/** Localized copy consumed by the framework-free zoom controls. */
|
||||
export interface ZoomLabels {
|
||||
readonly controls: string
|
||||
readonly menu: string
|
||||
readonly out: string
|
||||
readonly into: string
|
||||
export interface ZoomLabels extends DocumentZoomLabels {
|
||||
readonly fitWidth: string
|
||||
readonly value: (percent: number) => string
|
||||
}
|
||||
Reference in new issue
Block a user