fix(web): review-round attachment refinements
Body-portal the lightbox and toast so transformed ancestors cannot trap their fixed positioning (a lightbox opened from a chat message covered only the chat column); make the toast pointer-transparent; observe the rail element's own size instead of window resizes; consume vertical wheel ticks exclusively via a non-passive listener with LINE/PAGE delta normalization; keep the start position when the rail mounts over an existing draft; honor prefers-reduced-motion for the toast, remove-control, and paging; retry loads through the guarded load effect; note the deliberate promptError re-announce; pin the intake toast in the assembled snapshot; sync the superseded multimodal note and package docs.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
|
||||
README.md: a0a410532a631f243c3dfef7debfc6ce0bb0699e
|
||||
README.zh.md: 4d8ae638a30dc37d89531f15cf3c541d8e09f8f6
|
||||
README.md: 7e67a0064f5611ac814a9d489db0d4cc471309e1
|
||||
README.zh.md: bf44f6a42db0e1a2c06e1f42133918150b123c2e
|
||||
|
||||
@@ -6,7 +6,7 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
|
||||
|
||||
## Attachment rail
|
||||
|
||||
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling, and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and window resizes. A vertical wheel pans the rail horizontally with per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
|
||||
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). A vertical wheel pans the rail horizontally through a non-passive listener that consumes the event — the same tick never also scrolls the conversation — with LINE/PAGE deltas normalized to pixels and per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
|
||||
|
||||
## Message images and the lightbox
|
||||
|
||||
@@ -24,3 +24,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments.
|
||||
- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only.
|
||||
- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it (behavior carried over from the pre-package component).
|
||||
|
||||
@@ -6,21 +6,22 @@
|
||||
|
||||
## 附件栏
|
||||
|
||||
`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动,箭头的显隐在滚动、条目数量变化和窗口尺寸变化时依据滚动几何重算。纵向滚轮转为横向平移,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
|
||||
`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。纵向滚轮经非 passive 监听器转为横向平移并独占消费该事件,同一次滚动不会同时滚动会话记录;LINE/PAGE 单位的增量先归一化为像素,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
|
||||
|
||||
## 消息图片与灯箱
|
||||
|
||||
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
|
||||
|
||||
## Model Experience
|
||||
## 模型体验
|
||||
|
||||
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
|
||||
无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
#### KV Cache 影响
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
|
||||
- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。
|
||||
- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1"
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -81,6 +81,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.remove {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* by edge arrows, hover-revealed per-item remove, single-click open. */
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { WheelEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
|
||||
@@ -33,15 +32,31 @@ export interface AttachmentRailLabels {
|
||||
scrollRight: string
|
||||
}
|
||||
|
||||
/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox
|
||||
* notch wheels report lines, not pixels). */
|
||||
const WHEEL_LINE_PX = 16
|
||||
|
||||
/** Smooth paging unless the user asked for reduced motion. */
|
||||
function pageBehavior(): ScrollBehavior {
|
||||
// jsdom (the unit lane) implements no matchMedia despite lib.dom's
|
||||
// non-optional typing; the optional call keeps that lane on the default.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal thumbnail rail over the caller's draft attachments.
|
||||
*
|
||||
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
|
||||
* arrows recomputed from scroll geometry on scroll, item-count changes, and
|
||||
* window resizes. A vertical wheel pans horizontally, a newly added item is
|
||||
* revealed at the rail's end, and each thumbnail opens on a single click while
|
||||
* its remove control sits inside the card and reveals on hover or focus.
|
||||
* The owner decides mounting; it renders the rail only while items exist.
|
||||
* rail size changes (a ResizeObserver on the rail element, so sidebar or
|
||||
* panel resizes count, not only window resizes). A vertical wheel pans the
|
||||
* rail horizontally and is consumed exclusively (non-passive listener), a
|
||||
* newly added item is revealed at the rail's end while a rail that mounts
|
||||
* over an existing draft keeps its start position, and each thumbnail opens
|
||||
* on a single click while its remove control sits inside the card and
|
||||
* reveals on hover or focus. The owner decides mounting; it renders the rail
|
||||
* only while items exist.
|
||||
*
|
||||
* @param props.items - resolved thumbnails in draft order.
|
||||
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
|
||||
@@ -56,7 +71,10 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
||||
onRemove: (item: T) => void
|
||||
}) {
|
||||
const railRef = useRef<HTMLDivElement | null>(null)
|
||||
const countRef = useRef(0)
|
||||
// null marks the first layout pass: a rail that MOUNTS over an existing
|
||||
// draft (session switch back to held images) is initial display, not
|
||||
// growth, and must not jump to the end.
|
||||
const countRef = useRef<number | null>(null)
|
||||
const [edges, setEdges] = useState({ left: false, right: false })
|
||||
const updateEdges = useCallback(() => {
|
||||
const el = railRef.current
|
||||
@@ -68,16 +86,51 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
||||
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
|
||||
}, [])
|
||||
useLayoutEffect(() => {
|
||||
const grew = items.length > countRef.current
|
||||
const grew = countRef.current !== null && items.length > countRef.current
|
||||
countRef.current = items.length
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the layout effect always finds it. */
|
||||
if (el === null) return
|
||||
// A newly added attachment lands at the rail's end: reveal it.
|
||||
if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth
|
||||
if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
|
||||
updateEdges()
|
||||
}, [items.length, updateEdges])
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', updateEdges)
|
||||
return () => { window.removeEventListener('resize', updateEdges) }
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the mount effect always finds it. */
|
||||
if (el === null) return
|
||||
// The rail's width follows the composer, which resizes with sidebars and
|
||||
// panels, not only the window — observe the element itself. jsdom (the
|
||||
// unit lane) implements no ResizeObserver; every browser gets the
|
||||
// subscription.
|
||||
let disconnect = (): void => {}
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(updateEdges)
|
||||
observer.observe(el)
|
||||
disconnect = () => { observer.disconnect() }
|
||||
}
|
||||
// A vertical wheel pans the rail horizontally and is consumed: without
|
||||
// preventDefault the same tick would also scroll the conversation behind
|
||||
// the composer. React's root wheel listener is passive, so the exclusive
|
||||
// conversion needs this manually attached non-passive listener. LINE and
|
||||
// PAGE deltas (Firefox notch wheels) are normalized to pixels before the
|
||||
// per-tick clamp that keeps a fast wheel followable.
|
||||
const onWheel = (event: globalThis.WheelEvent): void => {
|
||||
if (event.deltaX !== 0 || event.deltaY === 0) return
|
||||
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? WHEEL_LINE_PX
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
|
||||
event.preventDefault()
|
||||
el.scrollBy({
|
||||
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
|
||||
behavior: 'auto',
|
||||
})
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => {
|
||||
disconnect()
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
}
|
||||
}, [updateEdges])
|
||||
const page = (direction: -1 | 1): void => {
|
||||
const el = railRef.current
|
||||
@@ -85,16 +138,7 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
||||
if (el === null) return
|
||||
// One viewport minus a card keeps the last visible thumbnail as context;
|
||||
// the floor keeps narrow rails paging a useful distance.
|
||||
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: 'smooth' })
|
||||
}
|
||||
// A vertical wheel pans the rail horizontally (trackpads pan natively via
|
||||
// deltaX); per-tick travel is clamped so a fast notch wheel stays followable.
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
|
||||
if (event.deltaX !== 0 || event.deltaY === 0) return
|
||||
event.currentTarget.scrollBy({
|
||||
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60),
|
||||
behavior: 'auto',
|
||||
})
|
||||
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
|
||||
}
|
||||
return (
|
||||
<div className={css.root}>
|
||||
@@ -114,7 +158,6 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
||||
role="group"
|
||||
aria-label={labels.group}
|
||||
onScroll={updateEdges}
|
||||
onWheel={onWheel}
|
||||
>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className={css.item}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './ImageLightbox.module.css'
|
||||
|
||||
/** Lightbox strings the owner resolves from its own locale namespace. */
|
||||
@@ -12,7 +13,9 @@ export interface ImageLightboxLabels {
|
||||
/**
|
||||
* Document-level original-image preview opened by clicking a thumbnail.
|
||||
* Closes on Escape, backdrop press, or the close control, and restores focus
|
||||
* to the opener on unmount.
|
||||
* to the opener on unmount. Rendered through a body portal: an opener inside
|
||||
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
|
||||
* in that ancestor's box instead of covering the viewport.
|
||||
*
|
||||
* @param props.src - the original image URL.
|
||||
* @param props.alt - the image's alt text.
|
||||
@@ -42,7 +45,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={css.backdrop}
|
||||
role="dialog"
|
||||
@@ -52,6 +55,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
|
||||
>
|
||||
<img className={css.image} src={src} alt={alt} />
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,24 +40,23 @@ export function MessageImage({ attachment, load, labels }: {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
// Retry re-arms the one load effect below, so every attempt — first load or
|
||||
// retry — runs under the same liveness guard and the same reset.
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
const size = useMemo(() => {
|
||||
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
|
||||
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
|
||||
}, [attachment.height, attachment.width])
|
||||
|
||||
const request = useCallback(() => {
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then(setSrc).catch(() => { setError(true) })
|
||||
}, [attachment, load])
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
|
||||
return () => { live = false }
|
||||
}, [attachment, load])
|
||||
}, [attachment, load, attempt])
|
||||
|
||||
const label = attachment.name ?? labels.image
|
||||
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
|
||||
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
|
||||
// vertical-wheel pan, and the new-item end reveal.
|
||||
// exclusive vertical-wheel pan, and the new-item end reveal.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { AttachmentRail } from '../src/AttachmentRail.tsx'
|
||||
import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// jsdom implements no ResizeObserver; the stub records instances so a test
|
||||
// can drive the size-change recompute path.
|
||||
const observers: { callback: ResizeObserverCallback; observed: Element[] }[] = []
|
||||
beforeEach(() => {
|
||||
observers.length = 0
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observed: Element[] = []
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
observers.push({ callback, observed: this.observed })
|
||||
}
|
||||
|
||||
observe(el: Element) { this.observed.push(el) }
|
||||
disconnect() { this.observed.length = 0 }
|
||||
})
|
||||
})
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
const labels: AttachmentRailLabels = {
|
||||
group: '待发送图片',
|
||||
open: '查看原图',
|
||||
@@ -78,34 +95,58 @@ describe('AttachmentRail', () => {
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows both arrows mid-scroll and recomputes on window resize', () => {
|
||||
it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
setScrollLeft(100)
|
||||
fireEvent(window, new Event('resize'))
|
||||
// The component observes the rail element, not the window: a sidebar or
|
||||
// panel resize reaches it through the ResizeObserver callback.
|
||||
expect(observers.at(-1)?.observed).toContain(rail)
|
||||
act(() => { observers.at(-1)!.callback([], undefined as never) })
|
||||
expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pans horizontally on a vertical wheel with clamped travel', () => {
|
||||
it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
fireEvent.wheel(rail, { deltaY: 30 })
|
||||
// Converted ticks are consumed (preventDefault): fireEvent returns false.
|
||||
expect(fireEvent.wheel(rail, { deltaY: 30 })).toBe(false)
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: 500 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: -500 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
|
||||
// Firefox notch wheels report lines; a page-mode wheel reports viewports.
|
||||
fireEvent.wheel(rail, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
|
||||
// A trackpad pan (deltaX) and a zero-delta wheel keep native behavior.
|
||||
fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })
|
||||
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(true)
|
||||
fireEvent.wheel(rail, { deltaY: 0 })
|
||||
expect(scrollBy).toHaveBeenCalledTimes(3)
|
||||
expect(scrollBy).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('pages instantly under a reduced-motion preference, smoothly otherwise', () => {
|
||||
for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
fireEvent.scroll(rail)
|
||||
fireEvent.click(view.getByLabelText('向右滚动图片'))
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior })
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('reveals the rail end when an item is added, not when one is removed', () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { clientOnly } from '../tsdown.client.ts'
|
||||
|
||||
// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only
|
||||
// the package differs). On a third atoms package, extract a shared css-stub
|
||||
// client-library preset in packages/client/tsdown.client.ts instead of a
|
||||
// fourth copy.
|
||||
/**
|
||||
* ui-attachment is browser-only, but its lib bundle IS imported under plain
|
||||
* Node because the web shell is a lib (dsh-client-web's lib chain reaches
|
||||
|
||||
Reference in New Issue
Block a user