Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/Frontend/src/components/ActionButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ interface Props {
iconPosition?: "left" | "right";
disabled?: boolean;
loading?: boolean;
/** Loading normally disables the button; opt out for buttons that stay
* actionable while work runs (e.g. a cancel button). */
disableOnLoading?: boolean;
tooltip?: string;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
Expand All @@ -23,6 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
iconPosition: "left",
disabled: false,
loading: false,
disableOnLoading: true,
type: "button",
});

Expand All @@ -44,13 +48,15 @@ const sizeClasses = {
<template>
<button
class="btn"
:class="[variantClasses[props.variant], sizeClasses[props.size], { disabled: props.disabled || props.loading }]"
:disabled="props.disabled || props.loading"
:class="[variantClasses[props.variant], sizeClasses[props.size], { disabled: props.disabled || (props.loading && props.disableOnLoading) }]"
:disabled="props.disabled || (props.loading && props.disableOnLoading)"
:type="props.type"
:aria-label="props.ariaLabel"
v-tippy="props.tooltip"
>
<FAIcon v-if="props.icon && props.iconPosition === 'left' && !props.loading" :icon="props.icon" class="icon-left" />
<slot v-if="!props.loading" name="icon">
<FAIcon v-if="props.icon && props.iconPosition === 'left'" :icon="props.icon" class="icon-left" />
</slot>
<FAIcon v-if="props.loading" class="rotate" :icon="faRefresh" />
<span v-if="$slots.default" class="button-text">
<slot />
Expand Down
18 changes: 11 additions & 7 deletions src/Frontend/src/components/AutoRefreshIndicator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,23 @@ describe("FEATURE: Auto refresh indicator", () => {
expect(screen.queryByTestId("auto-refresh-indicator")).not.toBeInTheDocument();
});

test("EXAMPLE: A countdown bar is shown while waiting for the next refresh", () => {
test("EXAMPLE: A countdown ring is shown while waiting for the next refresh", () => {
render(AutoRefreshIndicator, { props: { nextRefreshAt: Date.now() + 2500, intervalMs: 5000, refreshing: false } });

expect(screen.getByTestId("auto-refresh-indicator")).toBeInTheDocument();
const bar = screen.getByTestId("auto-refresh-countdown");
const width = parseFloat(bar.style.width);
expect(width).toBeGreaterThan(25);
expect(width).toBeLessThan(75);
const indicator = screen.getByTestId("auto-refresh-indicator");
expect(indicator.dataset.state).toBe("countdown");
const ring = screen.getByTestId("auto-refresh-countdown");
// roughly half depleted: dash offset strictly between empty (0) and full circumference
const offset = parseFloat(ring.getAttribute("stroke-dashoffset")!);
const circumference = 2 * Math.PI * 6;
expect(offset).toBeGreaterThan(circumference * 0.25);
expect(offset).toBeLessThan(circumference * 0.75);
});

test("EXAMPLE: A waiting state is shown when the refresh is due but the query is still running", () => {
test("EXAMPLE: A waiting spinner ring is shown when the refresh is due but the query is still running", () => {
render(AutoRefreshIndicator, { props: { nextRefreshAt: Date.now(), intervalMs: 5000, refreshing: true } });

expect(screen.getByTestId("auto-refresh-indicator").dataset.state).toBe("waiting");
expect(screen.getByTestId("auto-refresh-waiting")).toBeInTheDocument();
expect(screen.queryByTestId("auto-refresh-countdown")).not.toBeInTheDocument();
});
Expand Down
90 changes: 60 additions & 30 deletions src/Frontend/src/components/AutoRefreshIndicator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,50 +19,80 @@ const fraction = computed(() => {
});

const secondsLeft = computed(() => (props.nextRefreshAt === null ? null : Math.max(0, Math.ceil((props.nextRefreshAt - now.value) / 1000))));

// r=6.75 in a 16x16 viewBox (filling the box like a FontAwesome glyph does);
// the arc depletes as the next refresh approaches
const circumference = 2 * Math.PI * 6.75;
const dashOffset = computed(() => (fraction.value === null ? 0 : circumference * (1 - fraction.value)));

const label = computed(() => (props.refreshing ? "Waiting for query results…" : `Auto refresh in ${secondsLeft.value}s`));
</script>

<!-- Purely visual: hidden from assistive tech. The host exposes the countdown as text
(see RefreshConfig), so a screen reader never gets an SVG or a label that changes
every second as the name of the button this ring sits in. -->
<template>
<div
v-if="fraction !== null"
class="auto-refresh-indicator"
role="timer"
:aria-label="refreshing ? 'Waiting for query results' : `Auto refresh in ${secondsLeft} seconds`"
:title="refreshing ? 'Waiting for query results…' : `Auto refresh in ${secondsLeft}s`"
data-testid="auto-refresh-indicator"
>
<div v-if="refreshing" class="bar waiting" data-testid="auto-refresh-waiting" />
<div v-else class="bar" :style="{ width: `${fraction * 100}%` }" data-testid="auto-refresh-countdown" />
</div>
<span v-if="fraction !== null" class="auto-refresh-indicator" aria-hidden="true" :title="label" data-testid="auto-refresh-indicator" :data-state="refreshing ? 'waiting' : 'countdown'">
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle class="track" cx="8" cy="8" r="6.75" />
<circle v-if="!refreshing" class="progress" cx="8" cy="8" r="6.75" :stroke-dasharray="circumference" :stroke-dashoffset="dashOffset" data-testid="auto-refresh-countdown" />
<circle v-else class="waiting" cx="8" cy="8" r="6.75" :stroke-dasharray="`${circumference / 5} ${circumference / 5}`" data-testid="auto-refresh-waiting" />
</svg>
</span>
</template>

<style scoped>
.auto-refresh-indicator {
width: 100%;
height: 3px;
margin-top: 0.25rem;
background-color: #e0e0e0;
border-radius: 2px;
overflow: hidden;
/* mirror FontAwesome's sizing so the ring occupies exactly the arrow icon's box */
display: inline-block;
line-height: 0;
vertical-align: -0.125em;
}

svg {
width: 1em;
height: 1em;
}

.bar {
height: 100%;
background-color: #00729c;
transition: width 250ms linear;
circle {
fill: none;
stroke-width: 2.4;
}

.bar.waiting {
width: 100%;
animation: waiting-pulse 1.2s ease-in-out infinite;
.track {
stroke: #e3e3e3;
}

.progress {
stroke: #00729c;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 250ms linear;
}

.waiting {
stroke: #b0b0b0;
animation: ring-spin 1.6s linear infinite;
transform-origin: center;
}

@media (prefers-reduced-motion: reduce) {
.progress {
transition: none;
}

.waiting {
animation: none;
}
}

@keyframes waiting-pulse {
0%,
100% {
opacity: 0.35;
@keyframes ring-spin {
from {
transform: rotate(0deg);
}
50% {
opacity: 0.9;
to {
transform: rotate(360deg);
}
}
</style>
Loading