-
- {t("usage.range.start")}
- {
- const value = event.currentTarget.value;
- setDraftWindow(current => ({ ...current, since: value }));
- setRangeError(null);
- }} />
-
-
- {t("usage.range.end")}
- {
- const value = event.currentTarget.value;
- setDraftWindow(current => ({ ...current, until: value }));
- setRangeError(null);
- }} />
-
-
{t("usage.range.apply")}
-
{t("usage.range.clear")}
+ {/*
+ An explicit interval is the rare path — the presets answer the question almost every
+ time — so the two date fields open on request instead of greeting every visit as the
+ second thing on the page. The applied interval stays outside the panel: collapsing the
+ controls must never hide which window the totals below actually cover.
+ */}
+
+
+
{
+ if (rangeOpen) setRangeError(null);
+ setRangeOpen(!rangeOpen);
+ }}
+ >
+ {t("usage.range.custom")}
+
+
+ {customWindow &&
{(() => {
+ const formatter = new Intl.DateTimeFormat(locale, {
+ year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
+ second: "2-digit", fractionalSecondDigits: 3, timeZoneName: "short",
+ });
+ return t("usage.range.applied", { start: formatter.format(customWindow.since), end: formatter.format(customWindow.until) });
+ })()}
}
- {t("usage.range.help")}
- {rangeError && {t(`usage.range.${rangeError}`)}
}
- {customWindow && {(() => {
- const formatter = new Intl.DateTimeFormat(locale, {
- year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
- second: "2-digit", fractionalSecondDigits: 3, timeZoneName: "short",
- });
- return t("usage.range.applied", { start: formatter.format(customWindow.since), end: formatter.format(customWindow.until) });
- })()}
}
-
+ {rangeOpen && (
+
+ )}
+
{/*
Only shown when connected. Naming the source is a two-plane concept: it answers
"which store served these numbers", and that question only exists once there are
diff --git a/gui/src/styles.css b/gui/src/styles.css
index a5838be7e4..1a51a2f40e 100644
--- a/gui/src/styles.css
+++ b/gui/src/styles.css
@@ -2508,6 +2508,45 @@ button.prov-account-row.active { cursor: default; }
/* Top-align with Storage: centering against the taller filter chips dropped the "Usage" title. */
.usage-head { flex-wrap: wrap; align-items: flex-start; }
.usage-filters { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
+/* Custom range: a disclosure, not a second filter row. It reuses the chip language of the
+ segmented presets above it so the page reads as one control area, and the panel is
+ `width: max-content` because a four-control form stretched to 1200px is what made the old
+ flex-end row look like a stray band across the page. */
+.usage-range { display: grid; justify-items: start; gap: 8px; margin: 10px 0 4px; }
+.usage-range-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; max-width: 100%; }
+.usage-range-toggle {
+ display: inline-flex; align-items: center; gap: 6px;
+ min-height: var(--control-sm); padding: 4px 12px;
+ border: 1px solid var(--border); border-radius: var(--radius-pill);
+ background: var(--surface); color: var(--muted);
+ font: inherit; font-size: var(--text-label); font-weight: var(--weight-medium);
+ white-space: nowrap; cursor: pointer;
+ transition: color var(--motion-fast), background var(--motion-fast), border-color var(--motion-fast);
+}
+.usage-range-toggle:hover { background: var(--raised); color: var(--text); }
+.usage-range-toggle[aria-expanded="true"] { color: var(--text); }
+.usage-range-toggle.is-active { border-color: var(--accent); color: var(--text); }
+.usage-range-chevron { flex: 0 0 auto; transition: transform var(--motion-fast); }
+.usage-range-toggle[aria-expanded="true"] .usage-range-chevron { transform: rotate(90deg); }
+.usage-range-applied { margin: 0; min-width: 0; }
+.usage-range-panel {
+ display: grid; gap: 10px; width: max-content; max-width: 100%;
+ padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-sm);
+ background: var(--surface);
+}
+/* `align-items: end` is the fix for the old row: a label+input stack is twice the height of a
+ `btn-sm`, so centering left Apply and Clear floating beside the middle of the fields.
+ Every track is `auto` on purpose. A fixed 200px cap does not survive contact with a
+ `datetime-local` control: its intrinsic minimum is about 206px in Chrome at this font size,
+ and it grows again for locales whose date format is longer than `mm/dd/yyyy`. The fields
+ then overflow their tracks and eat the gap, which is the exact rhythm defect this block
+ exists to fix. */
+.usage-range-fields { display: grid; grid-template-columns: repeat(4, auto); justify-content: start; align-items: end; gap: 8px; }
+.usage-range-field { display: grid; gap: 4px; min-width: 0; }
+.usage-range-field .field-label { margin: 0; }
+.usage-range-fields .input { height: var(--control-md); padding-block: 0; }
+.usage-range-action { min-height: var(--control-md); }
+.usage-range-panel > p { margin: 0; }
.usage-segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 2px; gap: 2px; background: var(--surface); }
.usage-segmented-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: none; background: transparent; color: var(--muted); padding: 4px 12px; border-radius: var(--radius-pill); cursor: pointer; font: inherit; white-space: nowrap; }
.usage-segmented-btn.active { background: var(--raised); color: var(--text); font-weight: var(--weight-semibold); }
@@ -2529,6 +2568,11 @@ button.prov-account-row.active { cursor: default; }
@media (max-width: 640px) {
.usage-source-btn .usage-source-label-collapsible { display: none; }
+ /* Two 200px columns plus both actions stop fitting a phone content width; the panel takes
+ the full row and every control keeps the same height it has on desktop. */
+ .usage-range-panel { width: 100%; }
+ .usage-range-fields { grid-template-columns: minmax(0, 1fr); }
+ .usage-range-action { width: 100%; }
}
@media (max-width: 360px) {
diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx
index db257ba6b2..ea4e98e51c 100644
--- a/gui/tests/usage-custom-range.test.tsx
+++ b/gui/tests/usage-custom-range.test.tsx
@@ -85,10 +85,13 @@ async function respond(index: number, marker: string, date?: string) {
await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); });
}
+const toggle = () => container.querySelector
(".usage-range-toggle")!;
const form = () => container.querySelector('form[aria-label="Custom date range"]')!;
const startInput = () => form().querySelectorAll('input[type="datetime-local"]')[0];
const endInput = () => form().querySelectorAll('input[type="datetime-local"]')[1];
-const interval = () => form().querySelector('[role="status"]')?.textContent;
+// The applied interval lives beside the trigger rather than inside the panel: collapsing the
+// controls must not hide which window the totals cover.
+const interval = () => container.querySelector('.usage-range-bar [role="status"]')?.textContent;
const error = () => form().querySelector('[role="alert"]')?.textContent;
const preset = (name: string) => container.querySelector(`button.usage-segmented-btn[aria-label="${name}"]`)!;
@@ -97,7 +100,13 @@ async function click(button: HTMLButtonElement) {
await act(async () => { button.click(); });
}
+// The date fields are behind a closed-by-default disclosure, so every draft starts by opening it.
+async function openRange() {
+ if (toggle().getAttribute("aria-expanded") !== "true") await click(toggle());
+}
+
async function enter(start: string, end: string) {
+ await openRange();
await act(async () => {
for (const [input, value] of [[startInput(), start], [endInput(), end]] as const) {
Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value);
@@ -357,3 +366,70 @@ test("each preset clears custom, including the retained preset; 7d never replace
expect(container.querySelectorAll(".heatmap-grid .heatmap-cell")).toHaveLength(7);
expect(preset("7d").getAttribute("aria-pressed")).toBe("false");
});
+
+test("the range panel is closed until asked for, and collapsing it keeps the applied interval readable", async () => {
+ await mount();
+ await respond(0, "preset-report-marker");
+ // Closed is the default: a page that opens on a report should not also open on two empty
+ // date fields, and the collapsed panel must leave no tab stops behind.
+ expect(toggle().getAttribute("aria-expanded")).toBe("false");
+ expect(toggle().textContent).toContain("Custom date range");
+ expect(container.querySelector('form[aria-label="Custom date range"]')).toBeNull();
+ expect(container.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
+ expect(toggle().className).not.toContain("is-active");
+ // Naming a panel that is not in the document would leave a dangling IDREF.
+ expect(toggle().hasAttribute("aria-controls")).toBe(false);
+
+ await click(toggle());
+ expect(toggle().getAttribute("aria-expanded")).toBe("true");
+ expect(toggle().getAttribute("aria-controls")).toBe(form().id);
+ expect(container.querySelectorAll('input[type="datetime-local"]')).toHaveLength(2);
+ expect(requests).toHaveLength(1);
+
+ await enter("2020-09-15T10:20", "2020-09-15T10:21");
+ await apply();
+ expect(requests[1].url).toBe(`${apiBase}/api/usage?range=30d&surface=all&${boundsQuery}`);
+ await respond(1, "custom-report-marker");
+ const applied = interval();
+ expect(applied).toContain("both inclusive");
+
+ // Collapsing hides the controls, never the state: the interval line and the marked trigger
+ // still say which window produced the numbers below.
+ await click(toggle());
+ expect(toggle().getAttribute("aria-expanded")).toBe("false");
+ expect(container.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
+ expect(interval()).toBe(applied);
+ expect(toggle().className).toContain("is-active");
+ expect(container.textContent).toContain("custom-report-marker");
+ expect(requests).toHaveLength(2);
+
+ // Reopening restores the draft that produced the applied window rather than empty fields.
+ await click(toggle());
+ expect(startInput().value).toBe("2020-09-15T10:20");
+ expect(endInput().value).toBe("2020-09-15T10:21");
+ await clear();
+ expect(interval()).toBeUndefined();
+ expect(toggle().className).not.toContain("is-active");
+ expect(startInput().value).toBe("");
+});
+
+test("closing the panel retires a validation error instead of parking it out of sight", async () => {
+ await mount();
+ await respond(0, "held-report-marker");
+ await enter("2020-09-16T10:20", "2020-09-15T10:20");
+ await apply();
+ expect(error()).toContain("The end must");
+ expect(startInput().getAttribute("aria-invalid")).toBe("true");
+ expect(startInput().getAttribute("aria-describedby")).toBe("usage-range-help usage-range-error");
+
+ // The alert only means something beside the fields that produced it, so it does not outlive
+ // the panel — but the draft that produced it does.
+ await click(toggle());
+ await click(toggle());
+ expect(error()).toBeUndefined();
+ expect(startInput().value).toBe("2020-09-16T10:20");
+ expect(startInput().getAttribute("aria-invalid")).toBe("false");
+ expect(startInput().getAttribute("aria-describedby")).toBe("usage-range-help");
+ expect(requests).toHaveLength(1);
+ expect(container.textContent).toContain("held-report-marker");
+});