mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(ui): keep activity spinners in phase (#57654)
This commit is contained in:
committed by
GitHub
Unverified
parent
4f93038061
commit
e877131159
@@ -1,4 +1,5 @@
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { syncSpinPhase } from "@opencode/ui/hooks"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TitlebarItem } from "@opencode/gui-extensions/sdk"
|
||||
import { useExtensionHost } from "./host"
|
||||
@@ -70,7 +71,10 @@ function TitlebarItemButton(props: { item: TitlebarItem; vertical?: boolean }) {
|
||||
class="flex shrink-0 items-center justify-center"
|
||||
classList={{ "size-7": props.vertical, "size-5": !props.vertical }}
|
||||
>
|
||||
<Show when={!props.item.busy} fallback={<span data-slot="titlebar-update-loader" aria-hidden="true" />}>
|
||||
<Show
|
||||
when={!props.item.busy}
|
||||
fallback={<span ref={syncSpinPhase} data-slot="titlebar-update-loader" aria-hidden="true" />}
|
||||
>
|
||||
<Show
|
||||
when={props.item.icon}
|
||||
fallback={
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComponentProps, For } from "solid-js"
|
||||
import { syncSpinPhase } from "../hooks/spin-phase"
|
||||
|
||||
const outerIndices = new Set([1, 2, 4, 7, 8, 11, 13, 14])
|
||||
|
||||
@@ -22,6 +23,7 @@ export function Spinner(props: {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
ref={syncSpinPhase}
|
||||
viewBox="0 0 15 15"
|
||||
data-component="spinner"
|
||||
classList={{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { splitProps, type ComponentProps } from "solid-js"
|
||||
import { syncSpinPhase } from "../../hooks/spin-phase"
|
||||
import "./activity-ring.css"
|
||||
|
||||
// An open arc in constant rotation, matching the iOS drawer's busy ring.
|
||||
@@ -8,6 +9,7 @@ export function ActivityRing(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
{...rest}
|
||||
ref={syncSpinPhase}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
width={local.width ?? 14}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { splitProps, type ComponentProps } from "solid-js"
|
||||
import { syncSpinPhase } from "../../hooks/spin-phase"
|
||||
import "./loader.css"
|
||||
|
||||
export function Loader(props: ComponentProps<"svg">) {
|
||||
@@ -7,6 +8,7 @@ export function Loader(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
{...rest}
|
||||
ref={syncSpinPhase}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
width={local.width ?? 16}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./use-filtered-list"
|
||||
|
||||
export * from "./create-auto-scroll"
|
||||
|
||||
export * from "./spin-phase"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { pinSpinPhase } from "./spin-phase"
|
||||
|
||||
test("pins only infinite loops to the timeline origin", () => {
|
||||
const animation = (iterations: number, startTime: number) => ({
|
||||
startTime,
|
||||
effect: { getTiming: () => ({ iterations }) },
|
||||
})
|
||||
|
||||
const loops = [animation(Infinity, 1234), animation(Infinity, 87)]
|
||||
const fade = animation(1, 1234)
|
||||
|
||||
pinSpinPhase({ getAnimations: () => [...loops, fade] })
|
||||
|
||||
expect(loops.map((loop) => loop.startTime)).toEqual([0, 0])
|
||||
expect(fade.startTime).toBe(1234)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
// ResizeObserver delivers after style and layout but before paint, so a spinner that mounts or leaves `display: none`
|
||||
// (which restarts its CSS animation) paints its first frame already in phase.
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? undefined
|
||||
: new ResizeObserver((entries) => entries.forEach((entry) => pinSpinPhase(entry.target)))
|
||||
|
||||
/** Use as a `ref` on a looping indicator so every instance on screen shares one phase. */
|
||||
export function syncSpinPhase(el: Element) {
|
||||
observer?.observe(el)
|
||||
// Turning reduced motion back off restarts the animation without resizing anything.
|
||||
el.addEventListener("animationstart", () => pinSpinPhase(el))
|
||||
onCleanup(() => observer?.unobserve(el))
|
||||
}
|
||||
|
||||
// Starting every infinite loop at the document timeline's origin puts all instances of one animation in phase.
|
||||
export function pinSpinPhase(el: {
|
||||
getAnimations(
|
||||
options: GetAnimationsOptions,
|
||||
): { startTime: CSSNumberish | null; effect: { getTiming(): EffectTiming } | null }[]
|
||||
}) {
|
||||
el.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getTiming().iterations === Infinity && animation.startTime !== 0)
|
||||
.forEach((animation) => {
|
||||
animation.startTime = 0
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user