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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
used `--zoom: 1` already; this only removes the now-dead compensation code.

### Added
- **The Dashboard now has a compact mobile presentation at the canonical
768px breakpoint** (#248). Its sticky header stays on one line with an
icon-only back link and Refresh action, an ellipsized Library title, and the
theme action; secondary favorite/source/update metadata and the desktop
layout selector are hidden. Every saved desktop layout is visually
overridden to one normal-height full-width column without changing its
persisted preference. Dashboard filters stay in one horizontally scrollable
row with fixed-position combobox popovers, while a Dashboard with no filters
omits the now-empty mobile toolbar entirely. Widening the viewport restores
the saved desktop layout and full controls automatically.
- **Favorited saved queries can now act as Dashboard Filter sources** (#160).
One explicit read-only query returns exactly one row containing any number of
`Array(T)`, `Array(Tuple(value T, label L))`, or `Map(K,V)` helpers. Exact
Expand Down
51 changes: 46 additions & 5 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2185,7 +2185,6 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
max-width: 1560px; margin: 0 auto;
}
@media (max-width: 1100px) { .dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .dash-grid { grid-template-columns: 1fr; } }
/* Report mode (#149 D2): one centered column (1100px) with taller tiles. The
.is-report/.is-wide selectors outweigh the responsive `.dash-grid` rules
above (class+class > single class), so both stay one column at every width —
Expand Down Expand Up @@ -2311,13 +2310,55 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
.dash-kpi-state-card[role='alert'] .dash-kpi-state-message { color: var(--error-fg); }
.dash-kpi-warnings { display: grid; gap: 4px; }
.dash-kpi-warning { color: var(--fg-mute); font-size: 12px; }
/* The canonical app mobile breakpoint: keep only the primary Dashboard
actions in one header row, visually normalize every saved desktop layout,
and let the shared filter controls scroll horizontally without clipping
their fixed-position popovers. These are CSS-only overrides, so widening
restores the persisted desktop layout without writing preferences. */
@media (max-width: 768px) {
.dash-header {
flex-wrap: nowrap; gap: 8px; padding: 8px 10px;
}
.dash-back {
width: 30px; height: 30px; padding: 0; justify-content: center; flex-shrink: 0;
}
.dash-back-label,
.dash-fav,
.dash-skip,
.dash-src,
.dash-updated,
.dash-spacer,
.dash-refresh-label,
.dash-layout-wrap { display: none; }
.dash-title {
flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.dash-icobtn,
.dash-refresh { width: 30px; height: 30px; flex-shrink: 0; }
.dash-refresh { padding: 0; justify-content: center; }

.dash-toolbar { flex-wrap: nowrap; padding: 6px 10px; }
.dash-toolbar:not(.has-filters) { display: none; }
.dash-filter-host { width: 100%; min-width: 0; overflow: hidden; }
.dash-filters {
width: 100%; flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
scrollbar-width: none; overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.dash-filters::-webkit-scrollbar { display: none; }
.dash-filters > .var-field { flex-shrink: 0; }

.dash-grid,
.dash-grid.is-wide,
.dash-grid.is-report {
grid-template-columns: 1fr; max-width: none; width: 100%; margin: 0; padding: 12px;
}
.dash-grid.is-report .dash-tile { min-height: 300px; }
}
@media (max-width: 520px) {
.dash-kpi-stream .kpi-card, .dash-kpi-state-card {
flex-basis: 100%; min-inline-size: 0; max-inline-size: none; inline-size: 100%;
min-width: 0; max-width: none; width: 100%;
}
}
@media (max-width: 640px) {
.dash-grid { grid-template-columns: 1fr; padding: 12px; }
.dash-header { padding: 10px 12px; gap: 8px; }
}
19 changes: 13 additions & 6 deletions src/ui/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -473,18 +473,23 @@ export function renderDashboard(app) {
h('span', null, favorites.length + (favorites.length === 1 ? ' favorite' : ' favorites')));
const skipNote = h('span', { class: 'dash-skip', style: { display: 'none' } });
const updated = h('span', { class: 'dash-updated' });
const refreshBtn = h('button', { class: 'dash-btn', title: 'Re-run all tiles' },
Icon.refresh(), h('span', null, 'Refresh'));
const refreshBtn = h('button', {
class: 'dash-btn dash-refresh', title: 'Re-run all tiles', 'aria-label': 'Refresh dashboard',
}, Icon.refresh(), h('span', { class: 'dash-refresh-label' }, 'Refresh'));
// Theme toggle, mirroring the workbench header: reuse app.toggleTheme (persists
// the pref + flips data-theme), and register the button as app.dom.themeBtn so
// that helper repaints its icon on toggle.
const themeBtn = h('button', { class: 'dash-icobtn', title: 'Toggle theme', onclick: () => app.toggleTheme() });
const themeBtn = h('button', {
class: 'dash-icobtn', title: 'Toggle theme', 'aria-label': 'Toggle theme', onclick: () => app.toggleTheme(),
});
themeBtn.appendChild(state.theme === 'dark' ? Icon.sun() : Icon.moon());
app.dom.themeBtn = themeBtn;

const header = h('div', { class: 'dash-header' },
h('a', { class: 'dash-back', href: app.basePath || '/sql', title: 'Back to SQL Browser' },
Icon.arrow(), h('span', null, 'SQL Browser')),
h('a', {
class: 'dash-back', href: app.basePath || '/sql', title: 'Back to SQL Browser',
'aria-label': 'Back to SQL Browser',
}, Icon.arrow(), h('span', { class: 'dash-back-label' }, 'SQL Browser')),
h('div', { class: 'dash-title' }, state.libraryName.value),
favChip,
skipNote,
Expand Down Expand Up @@ -550,7 +555,9 @@ export function renderDashboard(app) {
// The toolbar is flex-start (default), so layoutWrap + filterBar pack left as
// the issue specifies — no trailing spacer needed now the right-aligned
// Columns control is gone (#184).
const toolbar = h('div', { class: 'dash-toolbar' }, layoutWrap, filterHost);
const toolbar = h('div', {
class: 'dash-toolbar' + (controls.length ? ' has-filters' : ''),
}, layoutWrap, filterHost);
apply();

// #root is a fixed, overflow:hidden flex column (the workbench layout), so the
Expand Down
96 changes: 96 additions & 0 deletions tests/e2e/dashboard-mobile.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Dashboard mobile harness</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body data-theme="dark">
<div id="root">
<div class="dash-page">
<div class="dash-topbar">
<div class="dash-header">
<a class="dash-back" href="/sql" title="Back to SQL Browser" aria-label="Back to SQL Browser">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor"><path d="M2 6h7.5M7 3.5 9.5 6 7 8.5" /></svg>
<span class="dash-back-label">SQL Browser</span>
</a>
<div class="dash-title">A deliberately long production operations Dashboard name</div>
<span class="dash-chip dash-fav">6 favorites</span>
<span class="dash-skip">1 not shown</span>
<div class="dash-spacer" style="flex:1"></div>
<span class="dash-chip dash-src"><span class="dash-dot"></span>clickhouse.example.internal</span>
<span class="dash-updated">Updated 12:34</span>
<button class="dash-icobtn" title="Toggle theme" aria-label="Toggle theme">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor"><circle cx="7" cy="7" r="2.4" /></svg>
</button>
<button class="dash-btn dash-refresh" title="Re-run all tiles" aria-label="Refresh dashboard">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor"><path d="M10.5 6a4.5 4.5 0 1 1-1.3-3.2M10.5 1.5V4H8" /></svg>
<span class="dash-refresh-label">Refresh</span>
</button>
</div>
<div class="dash-toolbar has-filters">
<div class="dash-layout-wrap">
<span class="dash-seg-label">Layout</span>
<div class="dash-seg dash-seg-layout" role="group" aria-label="Dashboard layout">
<button class="dash-seg-btn">Full width</button>
<button class="dash-seg-btn is-active">Report</button>
<button class="dash-seg-btn">2 columns</button>
<button class="dash-seg-btn">3 columns</button>
</div>
</div>
<div class="dash-filter-host"><div class="dash-filters" role="group" aria-label="Dashboard filters"></div></div>
</div>
</div>
<div id="no-filter-toolbar" class="dash-toolbar">
<div class="dash-layout-wrap">Layout-only toolbar</div>
</div>
<main class="dash-grid is-report" aria-label="Dashboard panels">
<section class="dash-tile"><div class="dash-tile-head">Panel one</div></section>
<section class="dash-tile"><div class="dash-tile-head">Panel two</div></section>
<section class="dash-tile"><div class="dash-tile-head">Panel three</div></section>
<section class="dash-tile"><div class="dash-tile-head">Panel four</div></section>
</main>
</div>
</div>
<script type="module">
import { buildRecentField } from '/src/ui/recent-field.js';
import { wireComboInput } from '/src/ui/combobox.js';

const filters = document.querySelector('.dash-filters');
for (const name of ['region', 'service', 'environment', 'owner', 'status', 'version']) {
const field = buildRecentField({
document, name, type: 'String', value: '', baseTitle: `${name}: String`,
getRecents: (text) => ['alpha', 'beta', 'gamma'].filter((value) => value.includes(text.toLowerCase())),
onClearRecent: () => {}, onValueInput: () => {}, onCommit: () => {},
});
wireComboInput(field, { onValueInput: () => {}, onCommit: () => {} });
const label = document.createElement('label');
label.className = 'var-field';
label.innerHTML = `<span class="var-name">${name}</span>`;
label.append(field.el);
filters.append(label);
}

window.__prefs = { dashLayout: 'report', dashCols: 3 };
localStorage.setItem('asb:dashLayout', 'report');
localStorage.setItem('asb:dashCols', '3');
window.__layoutApplyCount = 0;
window.__setLayout = (mode) => {
const grid = document.querySelector('.dash-grid');
grid.classList.toggle('is-wide', mode === 'wide');
grid.classList.toggle('is-report', mode === 'report');
grid.style.setProperty('--dash-cols', mode === 'columns-2' ? '2' : '3');
window.__layoutApplyCount++;
};

window.__refreshCount = 0;
const refresh = document.querySelector('.dash-refresh');
refresh.addEventListener('click', () => {
refresh.disabled = true;
window.__refreshCount++;
setTimeout(() => { refresh.disabled = false; }, 80);
});
window.__ready = true;
</script>
</body>
</html>
154 changes: 154 additions & 0 deletions tests/e2e/dashboard-mobile.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { test, expect } from '@playwright/test';

async function openAt(page, width, height = 844) {
await page.setViewportSize({ width, height });
await page.goto('/tests/e2e/dashboard-mobile.html');
await page.waitForFunction(() => window.__ready === true);
}

test.describe('Dashboard mobile layout', () => {
test('keeps the accessible header on one line and truncates its title at phone widths', async ({ page }) => {
await openAt(page, 390);
const header = page.locator('.dash-header');
const title = page.locator('.dash-title');

await expect(page.getByRole('link', { name: 'Back to SQL Browser' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Refresh dashboard' })).toBeVisible();
await expect(page.locator('.dash-back-label')).toBeHidden();
await expect(page.locator('.dash-refresh-label')).toBeHidden();
for (const selector of ['.dash-fav', '.dash-skip', '.dash-src', '.dash-updated']) {
await expect(page.locator(selector)).toBeHidden();
}

const geometry = await header.evaluate((node) => {
const visible = [...node.children].filter((child) => getComputedStyle(child).display !== 'none');
const rects = visible.map((child) => child.getBoundingClientRect());
const title = node.querySelector('.dash-title');
return {
header: node.getBoundingClientRect(),
centers: rects.map((rect) => rect.top + rect.height / 2),
titleClientWidth: title.clientWidth,
titleScrollWidth: title.scrollWidth,
whiteSpace: getComputedStyle(title).whiteSpace,
textOverflow: getComputedStyle(title).textOverflow,
pageOverflow: document.documentElement.scrollWidth - innerWidth,
};
});
expect(Math.max(...geometry.centers) - Math.min(...geometry.centers)).toBeLessThan(2);
expect(geometry.header.height).toBeLessThanOrEqual(47);
expect(geometry.titleClientWidth).toBeLessThan(geometry.titleScrollWidth);
expect(geometry.whiteSpace).toBe('nowrap');
expect(geometry.textOverflow).toBe('ellipsis');
expect(geometry.pageOverflow).toBeLessThanOrEqual(0);

const refresh = page.getByRole('button', { name: 'Refresh dashboard' });
await refresh.click();
await expect(refresh).toBeDisabled();
await expect(refresh).toBeEnabled();
expect(await page.evaluate(() => window.__refreshCount)).toBe(1);
});

test('keeps title and actions reachable without viewport overflow at 360px', async ({ page }) => {
await openAt(page, 360, 800);
const result = await page.locator('.dash-header').evaluate((header) => {
const title = header.querySelector('.dash-title').getBoundingClientRect();
const theme = header.querySelector('.dash-icobtn').getBoundingClientRect();
const refresh = header.querySelector('.dash-refresh').getBoundingClientRect();
return {
wraps: Math.abs((title.top + title.height / 2) - (theme.top + theme.height / 2)) > 2
|| Math.abs((theme.top + theme.height / 2) - (refresh.top + refresh.height / 2)) > 2,
titleBeforeActions: title.right <= theme.left,
actionsInside: refresh.right <= innerWidth,
pageOverflow: document.documentElement.scrollWidth - innerWidth,
};
});
expect(result).toEqual({ wraps: false, titleBeforeActions: true, actionsInside: true, pageOverflow: 0 });
});

test('visually normalizes every saved layout on mobile and restores desktop CSS on resize', async ({ page }) => {
await openAt(page, 390);
for (const mode of ['wide', 'report', 'columns-2', 'columns-3']) {
await page.evaluate((next) => window.__setLayout(next), mode);
const layout = await page.locator('.dash-grid').evaluate((grid) => {
const tile = grid.querySelector('.dash-tile');
const style = getComputedStyle(grid);
return {
columns: style.gridTemplateColumns.split(' ').length,
maxWidth: style.maxWidth,
width: grid.getBoundingClientRect().width,
availableWidth: grid.closest('.dash-page').clientWidth,
tileMinHeight: getComputedStyle(tile).minHeight,
prefs: window.__prefs,
stored: [localStorage.getItem('asb:dashLayout'), localStorage.getItem('asb:dashCols')],
};
});
expect(layout.columns).toBe(1);
expect(layout.maxWidth).toBe('none');
expect(layout.width).toBe(layout.availableWidth);
expect(layout.tileMinHeight).toBe('300px');
expect(layout.prefs).toEqual({ dashLayout: 'report', dashCols: 3 });
expect(layout.stored).toEqual(['report', '3']);
}

await page.evaluate(() => window.__setLayout('report'));
const applyCount = await page.evaluate(() => window.__layoutApplyCount);
await page.setViewportSize({ width: 900, height: 844 });
await expect(page.locator('.dash-layout-wrap').first()).toBeVisible();
const restored = await page.locator('.dash-grid').evaluate((grid) => ({
maxWidth: getComputedStyle(grid).maxWidth,
tileMinHeight: getComputedStyle(grid.querySelector('.dash-tile')).minHeight,
applyCount: window.__layoutApplyCount,
stored: [localStorage.getItem('asb:dashLayout'), localStorage.getItem('asb:dashCols')],
}));
expect(restored).toEqual({ maxWidth: '1100px', tileMinHeight: '440px', applyCount, stored: ['report', '3'] });
});

test('scrolls filters in one row while fixed combobox content escapes clipping', async ({ page }) => {
await openAt(page, 390);
const filters = page.locator('.dash-filters');
const before = await filters.evaluate((node) => ({
clientWidth: node.clientWidth,
scrollWidth: node.scrollWidth,
fieldWidths: [...node.querySelectorAll('.var-field')].map((field) => field.getBoundingClientRect().width),
fieldTops: [...node.querySelectorAll('.var-field')].map((field) => field.getBoundingClientRect().top),
overflowX: getComputedStyle(node).overflowX,
}));
expect(before.scrollWidth).toBeGreaterThan(before.clientWidth);
expect(Math.max(...before.fieldTops) - Math.min(...before.fieldTops)).toBeLessThan(2);
expect(Math.min(...before.fieldWidths)).toBeGreaterThan(150);
expect(before.overflowX).toBe('auto');

await filters.evaluate((node) => { node.scrollLeft = node.scrollWidth; });
expect(await filters.evaluate((node) => node.scrollLeft)).toBeGreaterThan(0);
await filters.evaluate((node) => { node.scrollLeft = 0; });

const first = page.getByRole('combobox', { name: 'region' });
await first.focus();
await first.press('ArrowDown');
const list = page.locator('#var-recent-list-region');
await expect(list).toBeVisible();
const popover = await list.evaluate((node) => {
const input = document.querySelector('[aria-label="region"]');
const toolbar = document.querySelector('.dash-toolbar.has-filters');
const listRect = node.getBoundingClientRect();
const inputRect = input.getBoundingClientRect();
return {
position: getComputedStyle(node).position,
anchored: Math.abs(listRect.left - inputRect.left) < 2 && listRect.top >= inputRect.bottom,
escapesToolbar: listRect.bottom > toolbar.getBoundingClientRect().bottom,
pageOverflow: document.documentElement.scrollWidth - innerWidth,
};
});
expect(popover).toEqual({ position: 'fixed', anchored: true, escapesToolbar: true, pageOverflow: 0 });
await first.press('Enter');
await expect(first).toHaveValue('alpha');
});

test('removes an empty toolbar only on mobile', async ({ page }) => {
await openAt(page, 390);
await expect(page.locator('#no-filter-toolbar')).toBeHidden();
await page.setViewportSize({ width: 769, height: 844 });
await expect(page.locator('#no-filter-toolbar')).toBeVisible();
});
});
Loading