diff --git a/CHANGELOG.md b/CHANGELOG.md index 484746ca..ade20b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/styles.css b/src/styles.css index fd0b16f4..3e63a0ae 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 — @@ -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; } -} diff --git a/src/ui/dashboard.js b/src/ui/dashboard.js index 34b9750b..cb13810d 100644 --- a/src/ui/dashboard.js +++ b/src/ui/dashboard.js @@ -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, @@ -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 diff --git a/tests/e2e/dashboard-mobile.html b/tests/e2e/dashboard-mobile.html new file mode 100644 index 00000000..bfd384f3 --- /dev/null +++ b/tests/e2e/dashboard-mobile.html @@ -0,0 +1,96 @@ + + + + + Dashboard mobile harness + + + +
+
+
+
+ + + SQL Browser + +
A deliberately long production operations Dashboard name
+ 6 favorites + 1 not shown +
+ clickhouse.example.internal + Updated 12:34 + + +
+
+
+ Layout +
+ + + + +
+
+
+
+
+
+
Layout-only toolbar
+
+
+
Panel one
+
Panel two
+
Panel three
+
Panel four
+
+
+
+ + + diff --git a/tests/e2e/dashboard-mobile.spec.js b/tests/e2e/dashboard-mobile.spec.js new file mode 100644 index 00000000..a2ac62c6 --- /dev/null +++ b/tests/e2e/dashboard-mobile.spec.js @@ -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(); + }); +}); diff --git a/tests/unit/dashboard.test.js b/tests/unit/dashboard.test.js index ed80e45d..28811a84 100644 --- a/tests/unit/dashboard.test.js +++ b/tests/unit/dashboard.test.js @@ -381,9 +381,16 @@ describe('renderDashboard', () => { ]; const app = dashApp(favorites, vi.fn(async () => chartResult())); await renderDashboard(app); - expect(app.root.querySelector('.dash-header')).not.toBeNull(); - expect(app.root.querySelector('.dash-back')).not.toBeNull(); + const header = app.root.querySelector('.dash-header'); + const back = header.querySelector('.dash-back'); + const refresh = header.querySelector('.dash-refresh'); + expect(back.getAttribute('aria-label')).toBe('Back to SQL Browser'); + expect(back.querySelector('.dash-back-label').textContent).toBe('SQL Browser'); + expect(refresh.getAttribute('aria-label')).toBe('Refresh dashboard'); + expect(refresh.querySelector('.dash-refresh-label').textContent).toBe('Refresh'); + expect(header.querySelector('.dash-icobtn').getAttribute('aria-label')).toBe('Toggle theme'); expect(app.root.querySelector('.dash-fav').textContent).toContain('2 favorites'); + expect(app.root.querySelector('.dash-toolbar').classList.contains('has-filters')).toBe(false); expect(app.root.querySelectorAll('.dash-tile').length).toBe(2); expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull(); expect(app.root.querySelector('.dash-tile-foot').textContent).toContain('rows'); @@ -1179,6 +1186,7 @@ describe('renderDashboard — global filter bar (#149 D3)', () => { const filters = app.root.querySelector('.dash-filters'); expect(filters.style.display).toBe('none'); expect(filters.querySelectorAll('.var-field').length).toBe(0); + expect(app.root.querySelector('.dash-toolbar').classList.contains('has-filters')).toBe(false); }); it('a param declared with conflicting types across two favorites renders a plain input with a visible warning (#173 acceptance, review F1)', async () => { @@ -1223,6 +1231,7 @@ describe('renderDashboard — global filter bar (#149 D3)', () => { await renderDashboard(app); const filters = app.root.querySelector('.dash-filters'); expect(filters.style.display).not.toBe('none'); + expect(app.root.querySelector('.dash-toolbar').classList.contains('has-filters')).toBe(true); expect([...filters.querySelectorAll('.var-name')].map((n) => n.textContent)).toEqual(['year', 'region']); expect(fieldInput(app.root, 'year').value).toBe('2024'); });