fix(client): re-pin StateDot loaders whose animation restarts
Moving a loader's element (a keyed list reorder) or showing it again after a display:none ancestor restarts its CSS animations, and the mount ref does not run again, so the loader left the shared phase. The loader now pins its animations to document time zero on every animationstart.
This commit is contained in:
1 parent
e1c49269b2
commit
4b9d8ad6f7
5 files changed
+31
-6
No files matched your search
@@ -18,8 +18,8 @@
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: f7df84aad26a788d
|
||||
zh: 707e05d1491285e1
|
||||
en: f093268cd20a1c14
|
||||
zh: 861c1fae527264fb
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: f0e54915b896af5a
|
||||
zh: 2047520fd321be90
|
||||
|
||||
@@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `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. |
|
||||
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
|
||||
| `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. |
|
||||
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. |
|
||||
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. |
|
||||
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
|
||||
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
|
||||
| `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. |
|
||||
|
||||
@@ -52,7 +52,7 @@ kind: "package-library"
|
||||
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
|
||||
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
|
||||
| `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 |
|
||||
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 |
|
||||
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 |
|
||||
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
|
||||
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
|
||||
| `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 |
|
||||
|
||||
@@ -10,12 +10,23 @@ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' | 'idle'
|
||||
|
||||
/**
|
||||
* Pin the loader's CSS animations to document time zero. A CSS animation starts
|
||||
* when its element is inserted, so loaders mounted at different moments rotate
|
||||
* out of phase; one shared start time keeps every visible loader in step.
|
||||
* when its element is inserted and restarts when the element is moved or shown
|
||||
* again, so loaders started at different moments rotate out of phase; one shared
|
||||
* start time keeps every visible loader in step. Mount pins the first start; the
|
||||
* element's own `animationstart` listener pins each restart.
|
||||
* @param element - the mounted loader, or null on unmount.
|
||||
*/
|
||||
function syncSpinner(element: SVGSVGElement | null): void {
|
||||
if (element === null) return
|
||||
pinSpinner(element)
|
||||
element.addEventListener('animationstart', () => { pinSpinner(element) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set every CSS animation in the loader's subtree to start at document time zero.
|
||||
* @param element - the mounted loader.
|
||||
*/
|
||||
function pinSpinner(element: SVGSVGElement): void {
|
||||
// jsdom (the unit lane) implements no Web Animations despite lib.dom's
|
||||
// non-optional typing; the optional call leaves that lane unsynced.
|
||||
const spinner = element as { getAnimations?: SVGSVGElement['getAnimations'] }
|
||||
|
||||
@@ -81,4 +81,18 @@ describe('StateDot ongoing phase', () => {
|
||||
delete proto.getAnimations
|
||||
}
|
||||
})
|
||||
|
||||
it('re-pins animations that restart after mount', () => {
|
||||
const animations = [{ startTime: 0 }]
|
||||
const proto = SVGElement.prototype as { getAnimations?: () => { startTime: number }[] }
|
||||
proto.getAnimations = () => animations
|
||||
try {
|
||||
const { container } = render(<StateDot state="ongoing" />)
|
||||
animations[0]!.startTime = 438
|
||||
container.querySelector('circle')!.dispatchEvent(new Event('animationstart', { bubbles: true }))
|
||||
expect(animations[0]!.startTime).toBe(0)
|
||||
} finally {
|
||||
delete proto.getAnimations
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user