feat(grid): add clock-hand progress animation to auto refresh

This commit is contained in:
zipg
2026-09-24 12:13:47 +08:00
committed by GitHub
parent 6517284d95
commit e8e2625801
10 changed files with 185 additions and 2 deletions
@@ -602,6 +602,7 @@ const autoRefresh = useDataGridAutoRefresh({
});
const autoRefreshIntervalSeconds = autoRefresh.intervalSeconds;
const autoRefreshEnabled = autoRefresh.enabled;
const autoRefreshSweepKey = autoRefresh.sweepKey;
const autoRefreshLabel = computed(() => (autoRefreshEnabled.value ? t("tabs.autoRefreshEvery", { seconds: autoRefreshIntervalSeconds.value }) : t("tabs.autoRefresh")));
if (isDebugLoggingEnabled()) {
@@ -4270,6 +4271,7 @@ const autoRefreshToolbarCapability = computed<DataGridToolbarAutoRefreshCapabili
stopLabel: t("tabs.stopAutoRefresh"),
enabled: autoRefreshEnabled.value,
intervalSeconds: autoRefreshIntervalSeconds.value,
sweepKey: autoRefreshSweepKey.value,
intervalOptions: AUTO_REFRESH_INTERVAL_OPTIONS,
intervalLabel: (seconds) => t("tabs.autoRefreshEvery", { seconds }),
onToggle: toggleAutoRefresh,
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from "vue";
const props = withDefaults(
defineProps<{
enabled?: boolean;
intervalSeconds?: number;
sweepKey?: number;
}>(),
{ enabled: false, intervalSeconds: 10, sweepKey: 0 },
);
// One sweep == one refresh interval, so the hand reads as real progress toward
// the next tick without printing a digit that keeps changing under the user.
const sweepDuration = computed(() => `${Number.isFinite(props.intervalSeconds) && props.intervalSeconds > 0 ? props.intervalSeconds : 10}s`);
</script>
<template>
<svg class="data-grid-auto-refresh-clock data-grid-topbar-action-icon h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<line x1="10" x2="14" y1="2" y2="2" />
<circle cx="12" cy="14" r="8" />
<line :key="enabled ? sweepKey : 'idle'" class="data-grid-auto-refresh-hand" :class="{ 'data-grid-auto-refresh-hand--sweeping': enabled }" data-auto-refresh-hand x1="12" x2="12" y1="14" y2="8" :style="{ '--dbx-auto-refresh-sweep-duration': sweepDuration }" />
</svg>
</template>
<style scoped>
.data-grid-auto-refresh-hand {
transform-box: view-box;
transform-origin: 12px 14px;
}
.data-grid-auto-refresh-hand--sweeping {
animation: data-grid-auto-refresh-sweep var(--dbx-auto-refresh-sweep-duration, 10s) linear;
}
@keyframes data-grid-auto-refresh-sweep {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.data-grid-auto-refresh-hand--sweeping {
animation: none;
}
}
</style>
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed } from "vue";
import { Check, ChevronDown, Copy, Eye, Loader2, Map, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2, Upload } from "@lucide/vue";
import { Check, ChevronDown, Copy, Eye, Loader2, Map, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Trash2, Upload } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import DataGridAutoRefreshClock from "@/components/grid/DataGridAutoRefreshClock.vue";
import {
DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER,
dataGridToolbarIntervalOptions,
@@ -104,7 +105,7 @@ function actionLabelClass(action: DataGridToolbarActionKey) {
:aria-label="autoRefresh?.label"
:aria-pressed="autoRefresh?.enabled"
>
<Timer class="data-grid-topbar-action-icon h-3 w-3" />
<DataGridAutoRefreshClock :enabled="autoRefresh?.enabled === true" :interval-seconds="autoRefresh?.intervalSeconds" :sweep-key="autoRefresh?.sweepKey" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass('autoRefresh')">{{ autoRefresh?.enabled ? `${autoRefresh.intervalSeconds}s` : autoRefresh?.shortLabel }}</span>
</Button>
</DropdownMenuTrigger>
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import DataGridAutoRefreshClock from "@/components/grid/DataGridAutoRefreshClock.vue";
import { findOne, mountComponent, type HostNode } from "./vueHostHarness";
const clockPath = "apps/desktop/src/components/grid/DataGridAutoRefreshClock.vue";
const source = readFileSync(clockPath, "utf8");
function findHand(node: HostNode): HostNode {
return findOne(node, (candidate) => candidate.type === "line" && candidate.props["data-auto-refresh-hand"] !== undefined);
}
describe("DataGridAutoRefreshClock", () => {
it("keeps the stopwatch body and draws the hand from the centre", () => {
const host = mountComponent(DataGridAutoRefreshClock, { enabled: false });
expect(findOne(host.root, (node) => node.type === "svg").props["aria-hidden"]).toBe("true");
expect(findOne(host.root, (node) => node.type === "circle").props).toMatchObject({ cx: "12", cy: "14", r: "8" });
const hand = findHand(host.root);
expect(hand.props).toMatchObject({ x1: "12", y1: "14", x2: "12", y2: "8" });
host.unmount();
});
it("sweeps one full turn per interval while enabled", () => {
const host = mountComponent(DataGridAutoRefreshClock, { enabled: true, intervalSeconds: 30, sweepKey: 1 });
const hand = findHand(host.root);
expect(hand.props.class).toContain("data-grid-auto-refresh-hand--sweeping");
expect(hand.props.style["--dbx-auto-refresh-sweep-duration"]).toBe("30s");
expect(hand.props.style["transform-origin"]).toBeUndefined();
host.unmount();
});
it("parks the hand while auto-refresh is off", () => {
const host = mountComponent(DataGridAutoRefreshClock, { enabled: false, intervalSeconds: 5, sweepKey: 4 });
expect(findHand(host.root).props.class ?? "").not.toContain("--sweeping");
host.unmount();
});
it("re-creates the hand when the sweep key changes so the turn restarts", async () => {
const host = mountComponent(DataGridAutoRefreshClock, { enabled: true, intervalSeconds: 10, sweepKey: 1 });
const first = findHand(host.root);
await host.setProps({ sweepKey: 2 });
expect(findHand(host.root)).not.toBe(first);
host.unmount();
});
it("falls back to a ten second turn for missing or unusable intervals", () => {
for (const intervalSeconds of [undefined, Number.NaN, 0, -5]) {
const host = mountComponent(DataGridAutoRefreshClock, { enabled: true, intervalSeconds });
expect(findHand(host.root).props.style["--dbx-auto-refresh-sweep-duration"]).toBe("10s");
host.unmount();
}
});
it("rotates about the clock centre and opts out of motion on request", () => {
expect(source).toContain("transform-box: view-box");
expect(source).toContain("transform-origin: 12px 14px");
expect(source).toContain("animation: data-grid-auto-refresh-sweep var(--dbx-auto-refresh-sweep-duration, 10s) linear");
// House spinner idiom: the motion is cosmetic information, so honour the
// system preference rather than animating unconditionally.
expect(source).toContain("@media (prefers-reduced-motion: reduce)");
expect(source).toContain("animation: none");
});
});
@@ -29,4 +29,50 @@ describe("useDataGridAutoRefresh", () => {
expect(refresh).not.toHaveBeenCalled();
autoRefresh.stop();
});
it("bumps the sweep key when the countdown is armed and on every tick", () => {
vi.useFakeTimers();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => true), refresh: vi.fn(), initialIntervalSeconds: 5 });
expect(autoRefresh.sweepKey.value).toBe(0);
autoRefresh.toggle();
expect(autoRefresh.sweepKey.value).toBe(1);
vi.advanceTimersByTime(5000);
expect(autoRefresh.sweepKey.value).toBe(2);
vi.advanceTimersByTime(5000);
expect(autoRefresh.sweepKey.value).toBe(3);
autoRefresh.stop();
});
it("bumps the sweep key when the interval changes while enabled", () => {
vi.useFakeTimers();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => true), refresh: vi.fn(), initialIntervalSeconds: 5 });
autoRefresh.toggle();
expect(autoRefresh.sweepKey.value).toBe(1);
autoRefresh.setIntervalSeconds(30);
expect(autoRefresh.sweepKey.value).toBe(2);
expect(autoRefresh.intervalSeconds.value).toBe(30);
autoRefresh.stop();
});
it("keeps sweeping through skipped refreshes and freezes once disabled", () => {
vi.useFakeTimers();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => false), refresh: vi.fn(), initialIntervalSeconds: 1 });
autoRefresh.toggle();
vi.advanceTimersByTime(2000);
expect(autoRefresh.sweepKey.value).toBe(3);
autoRefresh.toggle();
vi.advanceTimersByTime(5000);
expect(autoRefresh.enabled.value).toBe(false);
expect(autoRefresh.sweepKey.value).toBe(3);
});
});
@@ -9,6 +9,10 @@ export interface UseDataGridAutoRefreshOptions {
export function useDataGridAutoRefresh(options: UseDataGridAutoRefreshOptions) {
const intervalSeconds = ref(options.initialIntervalSeconds ?? 10);
const enabled = ref(false);
// Bumped whenever the countdown is (re)armed: when the timer is started and on
// every tick. The toolbar keys its single-sweep clock hand off this, so the
// sweep restarts in phase with the real timer instead of free-running.
const sweepKey = ref(0);
let timer: ReturnType<typeof setInterval> | undefined;
function stop() {
@@ -17,6 +21,9 @@ export function useDataGridAutoRefresh(options: UseDataGridAutoRefreshOptions) {
}
function tick() {
// The sweep restarts even when the refresh itself is skipped: the timer is
// still counting down to the next attempt.
sweepKey.value += 1;
if (!enabled.value || !options.canRefresh.value) return;
void options.refresh();
}
@@ -24,6 +31,7 @@ export function useDataGridAutoRefresh(options: UseDataGridAutoRefreshOptions) {
function start() {
stop();
if (!enabled.value) return;
sweepKey.value += 1;
timer = setInterval(tick, intervalSeconds.value * 1000);
}
@@ -43,6 +51,7 @@ export function useDataGridAutoRefresh(options: UseDataGridAutoRefreshOptions) {
return {
intervalSeconds,
enabled,
sweepKey,
start,
stop,
setIntervalSeconds,
@@ -27,6 +27,7 @@ function autoRefreshCapability(overrides: Partial<DataGridToolbarAutoRefreshCapa
stopLabel: "Stop auto-refresh",
enabled: false,
intervalSeconds: 10,
sweepKey: 0,
intervalOptions: [5, 10, 30],
intervalLabel: (seconds) => `${seconds}s`,
onToggle: vi.fn(),
@@ -104,6 +104,8 @@ export interface DataGridToolbarAutoRefreshCapability {
disabled?: boolean;
enabled: boolean;
intervalSeconds: number;
/** Bumped whenever the auto-refresh countdown (re)starts, so the toolbar's clock hand restarts in phase with the real timer. */
sweepKey: number;
intervalOptions: readonly number[];
intervalLabel: (seconds: number) => string;
onToggle: () => void | Promise<void>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB