mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(grid): add clock-hand progress animation to auto refresh
This commit is contained in:
@@ -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 |
Reference in New Issue
Block a user