Merge pull request #55 from QRun-IO/feature/widget-width-readability
feat(widgets): readable dashboard widths (per-widget min width, ultra-wide caps, table card layout)
This commit was merged in pull request #55.
This commit is contained in:
16 files changed
+804
-38
No files matched your search
@@ -312,6 +312,21 @@ These examples are payload fragments emitted by an application's backend rendere
|
||||
{"gridColumns":6,"defaultValues":{"gridCols:sizeClass:xs":12,"gridCols:sizeClass:md":6}}
|
||||
```
|
||||
|
||||
**Readable widths (Next UI).** Without per-breakpoint overrides, a widget's span follows the width of the dashboard grid itself (CSS container queries, so an open sidebar counts), not the viewport. Two `defaultValues` hints tune it, and neither needs a QQQ change:
|
||||
|
||||
- `gridMinWidth`: the narrowest the widget may be, in rem (a number, or `"28rem"` / `"448px"`). The widget shares a row only when it, and a neighbour of the same minimum, each get at least that much; otherwise it takes the full row. Values snap up to the tiers 16, 20, 24, 28, 32, 40, 48 or 64 rem; anything wider always takes the full row. Two `gridColumns: 6` widgets with `gridMinWidth: 28` pair from a 57.5rem (920 px) grid and go to thirds (`gridColumns: 4`) from 86.5rem.
|
||||
- `gridProfile`: `widgets` (charts, statistics: half from a 40rem grid), `cards` or `table` (tables: half from 66rem), or `fields` (small tiles: half from 30rem). The default is `cards` for table-type widgets and `widgets` for the rest.
|
||||
|
||||
`gridCols:sizeClass:*` wins over `gridMinWidth`, which wins over `gridProfile`.
|
||||
|
||||
```json
|
||||
{"type":"table","gridColumns":6,"defaultValues":{"gridMinWidth":28}}
|
||||
```
|
||||
|
||||
Table widgets (`table`, `multiTable`) stop growing at 96rem (1536 px) so columns stay readable on ultra-wide screens; set `defaultValues.tableMaxWidth` to another width (rem, or a CSS length in `rem`, `px`, `ch`, `em` or `%`) or to `"none"`. Where a table widget is narrower than 30rem, or than its declared column widths need (6rem per `fr` or unsized column plus the fixed widths), each row renders as a stacked card of `label: value` lines instead of a table that scrolls sideways.
|
||||
|
||||
To cap a whole dashboard page on ultra-wide screens, give the app a supplemental app metadata of type `dashboardLayout` (a backend `QSupplementalAppMetaData` subclass whose `getType()` returns `dashboardLayout` and that is included in frontend metadata) with `maxContentWidth` (rem, or a CSS length). The page then centres at that width: `{"supplementalAppMetaData":{"dashboardLayout":{"maxContentWidth":160}}}`.
|
||||
|
||||
See [`widgetColumnClasses`](../../src/components/widgets/widget-utils.ts). Parent grids use the same sizing. A `parentWidget` payload such as `{"childWidgetNameList":["totals","trend"],"layoutType":"TABS"}` resolves those names in the instance widget registry. Children inherit parent request parameters and dropdown selections and fetch their own data; tab selection is remembered locally. A child omitted from permitted metadata cannot be configured into existence by listing its name.
|
||||
|
||||
**Dropdown data.** Metadata and response lists are paired by position; keep their lengths and order aligned. For the `region` dropdown example above, a response fragment is:
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Raise a cap only with a measured reason in the commit.",
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Readable widget widths (feature/widget-width-readability, on PR #50): the gridMinWidth container-query ladder (about 80 @container rules) and the table-widget card layout raise shared CSS from 20.4 KB (PR #50 CI) to 21.1 KB gzip, over the 21 KB cap, so the CSS cap becomes 22 KB (about 4% headroom); JS routes are unchanged. Raise a cap only with a measured reason in the commit.",
|
||||
"firstLoadJsGzipKB": 350,
|
||||
"routes": {
|
||||
"/": 255,
|
||||
@@ -18,7 +18,7 @@
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350
|
||||
},
|
||||
"cssGzipKB": 21,
|
||||
"cssGzipKB": 22,
|
||||
"totalJsGzipKB": 1050,
|
||||
"largestChunkGzipKB": 100,
|
||||
"lazyChunks": {
|
||||
|
||||
@@ -62,6 +62,16 @@ describe('AppHome', () => {
|
||||
server.use(http.post('*/table/carrier/count', () => HttpResponse.json({ count: 11 })))
|
||||
})
|
||||
|
||||
it('caps the page at supplementalAppMetaData.dashboardLayout.maxContentWidth, and is uncapped by default', () => {
|
||||
const { container, unmount } = renderHome({ name: 'wide', label: 'Wide', supplementalAppMetaData: { dashboardLayout: { maxContentWidth: 160 } } })
|
||||
const page = container.querySelector('[data-qqq-id="app-home-wide"]') as HTMLElement
|
||||
expect(page.style.maxWidth).toBe('160rem')
|
||||
expect(page.className).toContain('mx-auto')
|
||||
unmount()
|
||||
const plain = renderHome({ name: 'plain', label: 'Plain' })
|
||||
expect((plain.container.querySelector('[data-qqq-id="app-home-plain"]') as HTMLElement).style.maxWidth).toBe('')
|
||||
})
|
||||
|
||||
it('renders an app with no widgets, sections or children (v1 omits empty arrays) as the empty state', () => {
|
||||
renderHome({ name: 'empty', label: 'Empty App' })
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Empty App' })).toBeInTheDocument()
|
||||
|
||||
@@ -30,6 +30,8 @@ import { queryKeys } from '@/lib/query-client'
|
||||
import { isHiddenNode } from '@/lib/hooks/use-routes'
|
||||
import { canAccessProcess, canReadRecords, canRunReport } from '@/lib/auth/permissions'
|
||||
import { sanitizeQqqId } from '@/lib/utils/qqq-id'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { cssMaxWidth } from '@/lib/utils/responsive-grid'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
import { useWidgetAnchor } from '@/lib/hooks/use-widget-anchor'
|
||||
import { ConnectedWidget } from './ConnectedWidget'
|
||||
@@ -230,9 +232,12 @@ export function AppHome({ appMetaData, instance, widgetRegistry }: AppHomeProps)
|
||||
{ showAppLabelOnHomeScreen?: boolean; includeTableCountsOnHomeScreen?: boolean } | undefined
|
||||
const showLabel = homeSettings?.showAppLabelOnHomeScreen !== false
|
||||
const includeTableCounts = homeSettings?.includeTableCountsOnHomeScreen !== false
|
||||
// Optional page cap for ultra-wide screens (QSupplementalAppMetaData of type `dashboardLayout`)
|
||||
const dashboardLayout = appMetaData.supplementalAppMetaData?.dashboardLayout as { maxContentWidth?: unknown } | undefined
|
||||
const maxContentWidth = cssMaxWidth(dashboardLayout?.maxContentWidth)
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`app-home-${name}`}>
|
||||
<div className={cn('space-y-6', maxContentWidth && 'mx-auto w-full')} style={maxContentWidth ? { maxWidth: maxContentWidth } : undefined} data-qqq-id={`app-home-${name}`}>
|
||||
{/* App heading */}
|
||||
{showLabel ? (
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -200,3 +200,74 @@ describe('QqqMultiTableWidget', () => {
|
||||
expect(downloads.map((download) => download.fileName)).toEqual([expect.stringMatching(/^First \d{4}-\d\d-\d\d \d{4}\.csv$/), expect.stringMatching(/^Second /)])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Stubs ResizeObserver so every observed element reports `width` px.
|
||||
*
|
||||
* @param width - The reported content width.
|
||||
*/
|
||||
function observeWidth(width: number) {
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
private readonly callback: ResizeObserverCallback
|
||||
constructor(callback: ResizeObserverCallback) { this.callback = callback }
|
||||
observe(target: Element) { this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver) }
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
})
|
||||
}
|
||||
|
||||
describe('QqqTableWidget readable widths', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const data: QqqTablePayload = {
|
||||
columns: [
|
||||
{ header: 'Name', accessor: 'name', width: '1fr' },
|
||||
{ header: 'Notes', accessor: 'notes', width: '600px' },
|
||||
],
|
||||
rows: [{ name: 'Alpha', notes: 'First' }, { name: 'Beta' }],
|
||||
}
|
||||
|
||||
it('caps the content width at 96rem by default, or at defaultValues.tableMaxWidth, or not at all', () => {
|
||||
const { unmount } = render(<QqqTableWidget widgetMetaData={meta()} data={data} />)
|
||||
expect((document.querySelector('[data-qqq-id="table-widget-tbl"]') as HTMLElement).style.maxWidth).toBe('96rem')
|
||||
unmount()
|
||||
const second = render(<QqqTableWidget widgetMetaData={meta({ defaultValues: { tableMaxWidth: 120 } })} data={data} />)
|
||||
expect((document.querySelector('[data-qqq-id="table-widget-tbl"]') as HTMLElement).style.maxWidth).toBe('120rem')
|
||||
second.unmount()
|
||||
render(<QqqTableWidget widgetMetaData={meta({ defaultValues: { tableMaxWidth: 'none' } })} data={data} />)
|
||||
expect((document.querySelector('[data-qqq-id="table-widget-tbl"]') as HTMLElement).style.maxWidth).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the table (with its declared min width) when the widget is wide enough', () => {
|
||||
observeWidth(1200)
|
||||
render(<QqqTableWidget widgetMetaData={meta()} data={data} />)
|
||||
const root = document.querySelector('[data-qqq-id="table-widget-tbl"]') as HTMLElement
|
||||
expect(root.dataset.qqqTableLayout).toBe('table')
|
||||
expect(screen.getByRole('table').style.minWidth).toBe('calc(6rem + 600px)')
|
||||
expect(document.querySelector('colgroup')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders rows as labelled cards when the widget is narrower than its declared columns need', () => {
|
||||
// 600px + 6rem = 696px > 650px, although 650px is wider than 30rem
|
||||
observeWidth(650)
|
||||
render(<QqqTableWidget widgetMetaData={meta()} data={data} />)
|
||||
const root = document.querySelector('[data-qqq-id="table-widget-tbl"]') as HTMLElement
|
||||
expect(root.dataset.qqqTableLayout).toBe('cards')
|
||||
const table = screen.getByRole('table')
|
||||
// no forced min width or fixed column widths, so nothing can overflow sideways
|
||||
expect(table.style.minWidth).toBe('')
|
||||
expect(table.className).not.toContain('table-fixed')
|
||||
expect(document.querySelector('colgroup')).toBeNull()
|
||||
const cells = within(table).getAllByRole('cell')
|
||||
expect(cells.map((cell) => cell.dataset.label)).toEqual(['Name', 'Notes', 'Name', 'Notes'])
|
||||
expect(table.parentElement?.className).toContain('overflow-x-hidden')
|
||||
})
|
||||
|
||||
it('renders cards below 30rem even without declared widths', () => {
|
||||
observeWidth(400)
|
||||
render(<QqqTableWidget widgetMetaData={meta()} data={{ columns: [{ header: 'Name', accessor: 'name' }], rows: [{ name: 'Alpha' }] }} />)
|
||||
expect((document.querySelector('[data-qqq-id="table-widget-tbl"]') as HTMLElement).dataset.qqqTableLayout).toBe('cards')
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,12 @@
|
||||
* `fr` column widths, a sticky header, column-header help, paging (10 rows unless the
|
||||
* payload says otherwise) with numbered pages or a jump-to-page input and an optional
|
||||
* entries-per-page select, and in a multi-table widget an export button and footer per table.
|
||||
*
|
||||
* Readable at any width: the content stops growing at `defaultValues.tableMaxWidth` (96rem by
|
||||
* default) so columns stay close on ultra-wide screens, and where the widget is narrower than
|
||||
* 30rem, or than its declared column widths need, each row renders as a stacked card of
|
||||
* `label: value` lines (`data-qqq-table-layout="cards"`, styled in `globals.css`) instead of
|
||||
* a table that scrolls sideways.
|
||||
*/
|
||||
'use client'
|
||||
|
||||
@@ -31,7 +37,9 @@ import { ChevronDown, ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
|
||||
import type { QHelpContent, QWidgetHelpContent, QWidgetMetaData } from '@/types'
|
||||
import { useHelpHelpActive } from '@/lib/context/q-context'
|
||||
import { useContainerWidth } from '@/lib/hooks/use-container-width'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { cssMaxWidth } from '@/lib/utils/responsive-grid'
|
||||
import { selectSlotHelpContent, WIDGET_HELP_ROLES } from '@/lib/utils/help-utils'
|
||||
import { HelpContent } from '@/components/records/HelpContent'
|
||||
import type { QqqCompositeData, WidgetComponentProps } from './widget-types'
|
||||
@@ -43,7 +51,8 @@ import { WidgetLink } from './QqqStatisticsWidgets'
|
||||
import { QqqComposite } from './blocks/QqqComposite'
|
||||
import { downloadText, widgetCsvToString, widgetExportFileName } from './widget-utils'
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE, HIDDEN_COLUMN_TYPE, MAX_NUMBERED_PAGES, minTableWidth, pageSizeOptions, resolveColumnWidths, tableExportCsv,
|
||||
DEFAULT_TABLE_MAX_WIDTH, DEFAULT_TABLE_PAGE_SIZE, HIDDEN_COLUMN_TYPE, MAX_NUMBERED_PAGES, minTableWidth, pageSizeOptions, resolveColumnWidths, tableCardBreakpointPx,
|
||||
tableExportCsv,
|
||||
} from './table-widget-utils'
|
||||
import type { TableWidgetColumn } from './table-widget-utils'
|
||||
|
||||
@@ -152,6 +161,16 @@ function CellContent({ column, row, widgetMetaData, qqqId }: { column: TableWidg
|
||||
return <>{typeof value === 'number' && value !== 0 ? value.toLocaleString() : String(value)}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* The text a column's values are labelled with in card layout: its header, else its accessor.
|
||||
*
|
||||
* @param column - Column metadata.
|
||||
* @returns The label.
|
||||
*/
|
||||
function columnLabel(column: TableWidgetColumn): string {
|
||||
return String(column.header ?? column.accessor ?? '')
|
||||
}
|
||||
|
||||
/** Props for the table body renderer. */
|
||||
interface TableBodyProps {
|
||||
widgetMetaData: QWidgetMetaData
|
||||
@@ -180,6 +199,9 @@ function QqqTable({ widgetMetaData, table, suffix = '', ownChrome = false }: Tab
|
||||
const [pageDraft, setPageDraft] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
|
||||
const [exportMessage, setExportMessage] = useState<string | null>(null)
|
||||
const [measureRef, width] = useContainerWidth()
|
||||
const maxWidth = cssMaxWidth(widgetMetaData.defaultValues?.tableMaxWidth, DEFAULT_TABLE_MAX_WIDTH)
|
||||
const rootStyle = maxWidth ? { maxWidth } : undefined
|
||||
if (!allColumns || !rows || allColumns.some((column) => !isPlainObject(column)) || rows.some((row) => !isPlainObject(row))) {
|
||||
return <WidgetPayloadNotice widgetName={name} message={payloadProblem('table', 'columns/rows')} />
|
||||
}
|
||||
@@ -222,7 +244,7 @@ function QqqTable({ widgetMetaData, table, suffix = '', ownChrome = false }: Tab
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div data-qqq-id={qqqId}>
|
||||
<div data-qqq-id={qqqId} style={rootStyle}>
|
||||
{header}
|
||||
{exportStatus}
|
||||
{table.noRowsFoundHTML
|
||||
@@ -238,6 +260,8 @@ function QqqTable({ widgetMetaData, table, suffix = '', ownChrome = false }: Tab
|
||||
// the 60px expander column (Material) is one of the fixed widths the fr columns share around
|
||||
const declaredWidths = [...columns.map((column) => column.width), ...(hasSubRows ? ['60px'] : [])]
|
||||
const widths = resolveColumnWidths(declaredWidths)
|
||||
// width 0 = not measured (no ResizeObserver): keep the table
|
||||
const cards = width > 0 && width < tableCardBreakpointPx(declaredWidths, Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16)
|
||||
const stickyLast = table.fixedStickyLastRow === true && rows.length > 1
|
||||
const bodyRows = stickyLast ? rows.slice(0, -1) : rows
|
||||
const perPage = pageSize ?? (table.rowsPerPage && table.rowsPerPage > 0 ? table.rowsPerPage : DEFAULT_TABLE_PAGE_SIZE)
|
||||
@@ -276,7 +300,7 @@ function QqqTable({ widgetMetaData, table, suffix = '', ownChrome = false }: Tab
|
||||
data-depth={depth}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<td key={index} className={cellClass(column)}>
|
||||
<td key={index} className={cellClass(column)} data-label={columnLabel(column)}>
|
||||
<CellContent column={column} row={row} widgetMetaData={widgetMetaData} qqqId={`table-cell-${rowId}-${index}`} />
|
||||
</td>
|
||||
))}
|
||||
@@ -309,7 +333,7 @@ function QqqTable({ widgetMetaData, table, suffix = '', ownChrome = false }: Tab
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-qqq-id={qqqId}>
|
||||
<div ref={measureRef} data-qqq-id={qqqId} data-qqq-table-layout={cards ? 'cards' : 'table'} style={rootStyle}>
|
||||
{header}
|
||||
{exportStatus}
|
||||
{showPageSize && (
|
||||
@@ -329,13 +353,13 @@ function QqqTable({ widgetMetaData, table, suffix = '', ownChrome = false }: Tab
|
||||
<span aria-hidden="true">entries per page</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto" style={table.fixedHeight ? { maxHeight: table.fixedHeight } : undefined}>
|
||||
<div className={cards ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto'} style={table.fixedHeight ? { maxHeight: table.fixedHeight } : undefined}>
|
||||
<table
|
||||
className={cn('w-full border-collapse text-sm', widths && 'table-fixed')}
|
||||
style={widths ? { minWidth: minTableWidth(declaredWidths) } : undefined}
|
||||
className={cn('qqq-table-widget-table w-full border-collapse text-sm', widths && !cards && 'table-fixed')}
|
||||
style={widths && !cards ? { minWidth: minTableWidth(declaredWidths) } : undefined}
|
||||
aria-label={table.label ?? widgetMetaData.label}
|
||||
>
|
||||
{widths && (
|
||||
{widths && !cards && (
|
||||
<colgroup>
|
||||
{widths.map((width, index) => <col key={index} style={{ width }} />)}
|
||||
</colgroup>
|
||||
@@ -365,7 +389,7 @@ function QqqTable({ widgetMetaData, table, suffix = '', ownChrome = false }: Tab
|
||||
<tfoot>
|
||||
<tr className="sticky bottom-0 border-t-2 border-border bg-card font-semibold" data-qqq-id={`table-total-row-${name}${suffix}`}>
|
||||
{columns.map((column, index) => (
|
||||
<td key={index} className={cellClass(column)}>
|
||||
<td key={index} className={cellClass(column)} data-label={columnLabel(column)}>
|
||||
<CellContent column={column} row={rows[rows.length - 1]} widgetMetaData={widgetMetaData} qqqId={`table-total-cell-${name}${suffix}-${index}`} />
|
||||
</td>
|
||||
))}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { htmlToExportText, minTableWidth, pageSizeOptions, resolveColumnWidths, tableExportCsv } from './table-widget-utils'
|
||||
import { htmlToExportText, minTableWidth, pageSizeOptions, resolveColumnWidths, tableCardBreakpointPx, tableExportCsv } from './table-widget-utils'
|
||||
|
||||
describe('resolveColumnWidths', () => {
|
||||
it('keeps auto layout when no column declares a width', () => {
|
||||
@@ -47,6 +47,16 @@ describe('minTableWidth', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tableCardBreakpointPx', () => {
|
||||
it('is 30rem unless the declared columns need more', () => {
|
||||
expect(tableCardBreakpointPx([undefined, undefined])).toBe(480)
|
||||
expect(tableCardBreakpointPx(['2fr', '180px', undefined, '60px'])).toBe(480)
|
||||
expect(tableCardBreakpointPx(['1fr', '1fr', '1fr', '1fr', '1fr', '1fr'])).toBe(576)
|
||||
expect(tableCardBreakpointPx(['300px', '20rem', '1fr'])).toBe(300 + 320 + 96)
|
||||
expect(tableCardBreakpointPx(['30rem', '1fr'], 20)).toBe(600 + 120)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageSizeOptions', () => {
|
||||
it('offers the Material sizes plus the current one', () => {
|
||||
expect(pageSizeOptions(10)).toEqual([5, 10, 15, 20, 25])
|
||||
|
||||
@@ -86,6 +86,35 @@ export function minTableWidth(widths: Array<string | undefined>): string {
|
||||
return fixed.length === 0 ? `${flexible * 6}rem` : `calc(${[`${flexible * 6}rem`, ...fixed].join(' + ')})`
|
||||
}
|
||||
|
||||
/** Below this width (rem) a table widget always shows its rows as stacked cards. */
|
||||
export const TABLE_CARD_BREAKPOINT_REM = 30
|
||||
|
||||
/**
|
||||
* The narrowest a table widget can show its rows as a table without scrolling sideways, in px:
|
||||
* {@link minTableWidth} (6rem per flexible column plus the fixed widths) but never under
|
||||
* {@link TABLE_CARD_BREAKPOINT_REM}. Narrower than this, the rows render as stacked cards.
|
||||
* Fixed widths in `px`, `rem` or `em` count as declared; any other unit counts as 6rem.
|
||||
*
|
||||
* @param widths - Declared widths of the shown columns (and the expander), in order.
|
||||
* @param remPx - Pixels per rem.
|
||||
* @returns The width in px.
|
||||
*/
|
||||
export function tableCardBreakpointPx(widths: Array<string | undefined>, remPx = 16): number {
|
||||
const total = widths.reduce((sum, width) => {
|
||||
const match = /^(\d*\.?\d+)(px|rem|em)$/i.exec((width ?? '').trim())
|
||||
if (!match) return sum + 6 * remPx
|
||||
return sum + Number(match[1]) * (match[2].toLowerCase() === 'px' ? 1 : remPx)
|
||||
}, 0)
|
||||
return Math.max(TABLE_CARD_BREAKPOINT_REM * remPx, total)
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide a table widget's content may grow (table, header, paging), so a full-width table on
|
||||
* an ultra-wide screen keeps its columns close enough to read across: 96rem (1536px) unless the
|
||||
* widget's `defaultValues.tableMaxWidth` says otherwise (rem, a CSS length, or `none`).
|
||||
*/
|
||||
export const DEFAULT_TABLE_MAX_WIDTH = '96rem'
|
||||
|
||||
/**
|
||||
* The page-size choices to offer: the Material options plus the current size when it is not
|
||||
* one of them (a payload `rowsPerPage` of 7 stays selectable).
|
||||
|
||||
@@ -86,6 +86,24 @@ describe('widgetColumnClasses', () => {
|
||||
expect(widgetColumnClasses(6, undefined, 'table')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
|
||||
expect(widgetColumnClasses(6, undefined, 'statistics')).toBe('col-span-12 @min-[40rem]:col-span-6 min-w-0')
|
||||
})
|
||||
|
||||
it('honors a widget-declared minimum width (defaultValues.gridMinWidth) over its type profile', () => {
|
||||
expect(widgetColumnClasses(6, { gridMinWidth: 28 }, 'html')).toBe('col-span-12 @min-[57.5rem]:col-span-6 min-w-0')
|
||||
expect(widgetColumnClasses(6, { gridMinWidth: '448px' }, 'table')).toBe('col-span-12 @min-[57.5rem]:col-span-6 min-w-0')
|
||||
// an unusable value falls back to the type profile
|
||||
expect(widgetColumnClasses(6, { gridMinWidth: 'wide' }, 'table')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
|
||||
})
|
||||
|
||||
it('honors a widget-declared profile (defaultValues.gridProfile)', () => {
|
||||
expect(widgetColumnClasses(6, { gridProfile: 'table' }, 'html')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
|
||||
expect(widgetColumnClasses(6, { gridProfile: 'widgets' }, 'table')).toBe('col-span-12 @min-[40rem]:col-span-6 min-w-0')
|
||||
expect(widgetColumnClasses(6, { gridProfile: 'fields' })).toBe('col-span-12 @min-[30rem]:col-span-6 min-w-0')
|
||||
})
|
||||
|
||||
it('lets Material per-breakpoint sizes win over the hints, and gridMinWidth over gridProfile', () => {
|
||||
expect(widgetColumnClasses(6, { 'gridCols:sizeClass:md': 6, gridMinWidth: 28 })).toBe('col-span-12 min-[768px]:col-span-6 min-[1400px]:col-span-6 min-w-0')
|
||||
expect(widgetColumnClasses(6, { gridMinWidth: 16, gridProfile: 'table' })).toBe('col-span-12 @min-[33.5rem]:col-span-6 min-w-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plainText', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
import type { QHelpContent, QWidgetDropdown, QWidgetHelpContent, QWidgetMetaData } from '@/types'
|
||||
import { WIDGET_HELP_ROLES, selectSlotHelpContent } from '@/lib/utils/help-utils'
|
||||
import { containerSpanClasses } from '@/lib/utils/responsive-grid'
|
||||
import { containerSpanClasses, gridMinWidthRem, gridProfileOf, minWidthSpanClasses } from '@/lib/utils/responsive-grid'
|
||||
|
||||
/** Root of the local-storage keys that persist widget dropdown selections (shared with Material). */
|
||||
export const WIDGET_DROPDOWN_STORAGE_ROOT = 'qqq.widgets.dropdownData'
|
||||
@@ -327,6 +327,15 @@ function columnSpan(value: unknown): number | undefined {
|
||||
* `defaultValues`) is sized as Material sizes it: each declared breakpoint (Material's widths)
|
||||
* applies from its width up, `xs` defaults to 12 and `xxl` to `gridColumns`.
|
||||
*
|
||||
* Otherwise a widget may say how narrow it can get, in its `defaultValues`:
|
||||
* - `gridMinWidth`: its minimum width (rem as a number, or `"28rem"` / `"448px"`); it shares a
|
||||
* row only when the grid gives it, and a neighbour of the same minimum, at least that much
|
||||
* (see `minWidthSpanClasses`).
|
||||
* - `gridProfile`: `widgets` (charts, statistics), `cards` or `table` (tables), or `fields`
|
||||
* (small tiles), choosing one of the `responsive-grid` ladders instead of the type default.
|
||||
*
|
||||
* `gridCols:sizeClass:*` wins over `gridMinWidth`, which wins over `gridProfile`.
|
||||
*
|
||||
* @param gridColumns - Widget metadata grid columns.
|
||||
* @param defaultValues - Widget metadata default values (per-breakpoint overrides).
|
||||
* @param widgetType - Widget type, so table widgets get card-sized room.
|
||||
@@ -340,7 +349,10 @@ export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record
|
||||
const classes = SIZE_CLASSES.flatMap((size) => (sizes[size] ? [SIZE_CLASS_SPANS[size][sizes[size]]] : []))
|
||||
return `${classes.join(' ')} min-w-0`
|
||||
}
|
||||
return containerSpanClasses(columnSpan(gridColumns) ?? 12, widgetType && TABLE_WIDGET_TYPES.has(widgetType) ? 'cards' : 'widgets')
|
||||
const minWidth = gridMinWidthRem(defaultValues?.gridMinWidth)
|
||||
if (minWidth !== undefined) return minWidthSpanClasses(columnSpan(gridColumns), minWidth)
|
||||
const profile = gridProfileOf(defaultValues?.gridProfile) ?? (widgetType && TABLE_WIDGET_TYPES.has(widgetType) ? 'cards' : 'widgets')
|
||||
return containerSpanClasses(columnSpan(gridColumns) ?? 12, profile)
|
||||
}
|
||||
|
||||
/** Classes for a dashboard grid item: an in-page anchor target (`#widgetName`) clear of the header. */
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { containerSpanClasses, DEFAULT_FIELD_SPAN_CLASSES } from './responsive-grid'
|
||||
import {
|
||||
containerSpanClasses, cssMaxWidth, DEFAULT_FIELD_SPAN_CLASSES, GRID_GAP_REM, gridMinWidthRem, gridProfileOf, MAX_THRESHOLD_REM, MIN_WIDTH_TIERS,
|
||||
minWidthSpanClasses, minWidthThreshold,
|
||||
} from './responsive-grid'
|
||||
|
||||
/**
|
||||
* The span an item takes at each container level (base, L1, L2, L3), read back from its classes.
|
||||
@@ -61,3 +64,83 @@ describe('DEFAULT_FIELD_SPAN_CLASSES', () => {
|
||||
expect(spansAt(DEFAULT_FIELD_SPAN_CLASSES, ['24rem', '64rem', '96rem'])).toEqual([12, 6, 4, 3])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The span classes the min-width ladder should hold, recomputed from {@link minWidthThreshold}.
|
||||
*
|
||||
* @param minRem - Tier width.
|
||||
* @param span - Declared span.
|
||||
* @returns Expected classes.
|
||||
*/
|
||||
function expectedMinWidthClasses(minRem: number, span: number): string {
|
||||
const spans = [...new Set([6, 4, 3, 2, 1, span])].filter((t) => t >= span && t < 12).sort((a, b) => b - a)
|
||||
const steps = spans.filter((t) => minWidthThreshold(minRem, t) <= MAX_THRESHOLD_REM).map((t) => `@min-[${minWidthThreshold(minRem, t)}rem]:col-span-${t}`)
|
||||
return ['col-span-12', ...steps, 'min-w-0'].join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of an item spanning `span` twelfths of a grid `gridRem` wide, in rem.
|
||||
*
|
||||
* @param gridRem - Grid width.
|
||||
* @param span - Span.
|
||||
* @returns Item width.
|
||||
*/
|
||||
const itemWidth = (gridRem: number, span: number) => ((gridRem - 11 * GRID_GAP_REM) * span) / 12 + (span - 1) * GRID_GAP_REM
|
||||
|
||||
describe('minWidthSpanClasses', () => {
|
||||
it('matches the threshold formula for every tier and span (the static table is not hand-edited)', () => {
|
||||
for (const tier of MIN_WIDTH_TIERS) {
|
||||
for (let span = 1; span <= 12; span++) expect(minWidthSpanClasses(span, tier)).toBe(expectedMinWidthClasses(tier, span))
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every item at least its minimum at each threshold, and a neighbour of the same minimum the rest of the row', () => {
|
||||
for (const tier of MIN_WIDTH_TIERS) {
|
||||
for (let span = 1; span < 12; span++) {
|
||||
const grid = minWidthThreshold(tier, span)
|
||||
expect(itemWidth(grid, span)).toBeGreaterThanOrEqual(tier - 1e-9)
|
||||
expect(itemWidth(grid, 12 - span)).toBeGreaterThanOrEqual(tier - 1e-9)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('pairs two half-width 28rem tables only from a 57.5rem grid, thirds from 116rem', () => {
|
||||
const classes = minWidthSpanClasses(4, 28)
|
||||
expect(spansAt(classes, ['57.5rem', '86.5rem'])).toEqual([12, 6, 4])
|
||||
expect(classes).not.toContain('col-span-3')
|
||||
})
|
||||
|
||||
it('snaps a declared minimum up to the next tier and keeps oversize or missing spans full width', () => {
|
||||
expect(minWidthSpanClasses(6, 25)).toBe(minWidthSpanClasses(6, 28))
|
||||
expect(minWidthSpanClasses(6, 65)).toBe('col-span-12 min-w-0')
|
||||
expect(minWidthSpanClasses(undefined, 20)).toBe('col-span-12 min-w-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid hint parsing', () => {
|
||||
it('reads gridMinWidth as rem or px', () => {
|
||||
expect(gridMinWidthRem(28)).toBe(28)
|
||||
expect(gridMinWidthRem('28rem')).toBe(28)
|
||||
expect(gridMinWidthRem(' 448px ')).toBe(28)
|
||||
expect(gridMinWidthRem('20')).toBe(20)
|
||||
for (const bad of [0, -4, 'wide', '10vw', null, undefined, Number.NaN]) expect(gridMinWidthRem(bad)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads gridProfile, with table as an alias of cards', () => {
|
||||
expect(gridProfileOf('table')).toBe('cards')
|
||||
expect(gridProfileOf('cards')).toBe('cards')
|
||||
expect(gridProfileOf('widgets')).toBe('widgets')
|
||||
expect(gridProfileOf('fields')).toBe('fields')
|
||||
expect(gridProfileOf('TABLE')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads a max width as rem, a CSS length or none', () => {
|
||||
expect(cssMaxWidth(120)).toBe('120rem')
|
||||
expect(cssMaxWidth('1600px')).toBe('1600px')
|
||||
expect(cssMaxWidth('90ch')).toBe('90ch')
|
||||
expect(cssMaxWidth('none', '96rem')).toBeUndefined()
|
||||
expect(cssMaxWidth(0, '96rem')).toBeUndefined()
|
||||
expect(cssMaxWidth('calc(100% - 1px)', '96rem')).toBe('96rem')
|
||||
expect(cssMaxWidth(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -113,3 +113,208 @@ function normalizedSpan(span: number | undefined | null): number {
|
||||
export function containerSpanClasses(span: number | undefined | null, profile: GridItemProfile): string {
|
||||
return `${SPANS[profile][normalizedSpan(span)]} min-w-0`
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum-width tiers (rem) a widget may declare with `gridMinWidth`; a declared value snaps up
|
||||
* to the next tier, so the widget never gets narrower than it asked for.
|
||||
*/
|
||||
export const MIN_WIDTH_TIERS = [16, 20, 24, 28, 32, 40, 48, 64] as const
|
||||
|
||||
/** The dashboard grid gap (`gap-5`) the min-width thresholds allow for, in rem. */
|
||||
export const GRID_GAP_REM = 1.25
|
||||
|
||||
/** The widest grid (rem, a 3200px window) a min-width threshold is generated for. */
|
||||
export const MAX_THRESHOLD_REM = 200
|
||||
|
||||
/**
|
||||
* The narrowest 12-column grid (rem, rounded up to 0.5rem) in which an item spanning `span`
|
||||
* twelfths is at least `minRem` wide, and so is an item of the same minimum spanning the
|
||||
* rest of the row (`12 - span`), so a widget wider than half never leaves a hole too narrow
|
||||
* for its neighbour. An item of span t in a grid of width W is `(W - 11g) * t / 12 + (t - 1)g` wide.
|
||||
*
|
||||
* @param minRem - The item's minimum width in rem.
|
||||
* @param span - Declared span, 1 to 11.
|
||||
* @returns The grid width in rem.
|
||||
*/
|
||||
export function minWidthThreshold(minRem: number, span: number): number {
|
||||
const fits = (t: number) => (12 * (minRem - (t - 1) * GRID_GAP_REM)) / t + 11 * GRID_GAP_REM
|
||||
return Math.ceil(Math.max(fits(span), fits(12 - span)) * 2) / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Span classes per min-width tier and declared span: full width, then half, a third, a quarter,
|
||||
* a sixth (never narrower than declared, then the declared span itself) from the grid width at
|
||||
* which each fits ({@link minWidthThreshold}). Static strings for the Tailwind scanner; the
|
||||
* unit test regenerates them from the formula.
|
||||
*/
|
||||
const MIN_WIDTH_SPANS: Record<(typeof MIN_WIDTH_TIERS)[number], Record<number, string>> = {
|
||||
16: {
|
||||
1: 'col-span-12 @min-[33.5rem]:col-span-6 @min-[50.5rem]:col-span-4 @min-[68rem]:col-span-3 @min-[102.5rem]:col-span-2',
|
||||
2: 'col-span-12 @min-[33.5rem]:col-span-6 @min-[50.5rem]:col-span-4 @min-[68rem]:col-span-3 @min-[102.5rem]:col-span-2',
|
||||
3: 'col-span-12 @min-[33.5rem]:col-span-6 @min-[50.5rem]:col-span-4 @min-[68rem]:col-span-3',
|
||||
4: 'col-span-12 @min-[33.5rem]:col-span-6 @min-[50.5rem]:col-span-4',
|
||||
5: 'col-span-12 @min-[33.5rem]:col-span-6 @min-[40.5rem]:col-span-5',
|
||||
6: 'col-span-12 @min-[33.5rem]:col-span-6',
|
||||
7: 'col-span-12 @min-[40.5rem]:col-span-7',
|
||||
8: 'col-span-12 @min-[50.5rem]:col-span-8',
|
||||
9: 'col-span-12 @min-[68rem]:col-span-9',
|
||||
10: 'col-span-12 @min-[102.5rem]:col-span-10',
|
||||
11: 'col-span-12',
|
||||
12: 'col-span-12',
|
||||
},
|
||||
20: {
|
||||
1: 'col-span-12 @min-[41.5rem]:col-span-6 @min-[62.5rem]:col-span-4 @min-[84rem]:col-span-3 @min-[126.5rem]:col-span-2',
|
||||
2: 'col-span-12 @min-[41.5rem]:col-span-6 @min-[62.5rem]:col-span-4 @min-[84rem]:col-span-3 @min-[126.5rem]:col-span-2',
|
||||
3: 'col-span-12 @min-[41.5rem]:col-span-6 @min-[62.5rem]:col-span-4 @min-[84rem]:col-span-3',
|
||||
4: 'col-span-12 @min-[41.5rem]:col-span-6 @min-[62.5rem]:col-span-4',
|
||||
5: 'col-span-12 @min-[41.5rem]:col-span-6 @min-[50rem]:col-span-5',
|
||||
6: 'col-span-12 @min-[41.5rem]:col-span-6',
|
||||
7: 'col-span-12 @min-[50rem]:col-span-7',
|
||||
8: 'col-span-12 @min-[62.5rem]:col-span-8',
|
||||
9: 'col-span-12 @min-[84rem]:col-span-9',
|
||||
10: 'col-span-12 @min-[126.5rem]:col-span-10',
|
||||
11: 'col-span-12',
|
||||
12: 'col-span-12',
|
||||
},
|
||||
24: {
|
||||
1: 'col-span-12 @min-[49.5rem]:col-span-6 @min-[74.5rem]:col-span-4 @min-[100rem]:col-span-3 @min-[150.5rem]:col-span-2',
|
||||
2: 'col-span-12 @min-[49.5rem]:col-span-6 @min-[74.5rem]:col-span-4 @min-[100rem]:col-span-3 @min-[150.5rem]:col-span-2',
|
||||
3: 'col-span-12 @min-[49.5rem]:col-span-6 @min-[74.5rem]:col-span-4 @min-[100rem]:col-span-3',
|
||||
4: 'col-span-12 @min-[49.5rem]:col-span-6 @min-[74.5rem]:col-span-4',
|
||||
5: 'col-span-12 @min-[49.5rem]:col-span-6 @min-[59.5rem]:col-span-5',
|
||||
6: 'col-span-12 @min-[49.5rem]:col-span-6',
|
||||
7: 'col-span-12 @min-[59.5rem]:col-span-7',
|
||||
8: 'col-span-12 @min-[74.5rem]:col-span-8',
|
||||
9: 'col-span-12 @min-[100rem]:col-span-9',
|
||||
10: 'col-span-12 @min-[150.5rem]:col-span-10',
|
||||
11: 'col-span-12',
|
||||
12: 'col-span-12',
|
||||
},
|
||||
28: {
|
||||
1: 'col-span-12 @min-[57.5rem]:col-span-6 @min-[86.5rem]:col-span-4 @min-[116rem]:col-span-3 @min-[174.5rem]:col-span-2',
|
||||
2: 'col-span-12 @min-[57.5rem]:col-span-6 @min-[86.5rem]:col-span-4 @min-[116rem]:col-span-3 @min-[174.5rem]:col-span-2',
|
||||
3: 'col-span-12 @min-[57.5rem]:col-span-6 @min-[86.5rem]:col-span-4 @min-[116rem]:col-span-3',
|
||||
4: 'col-span-12 @min-[57.5rem]:col-span-6 @min-[86.5rem]:col-span-4',
|
||||
5: 'col-span-12 @min-[57.5rem]:col-span-6 @min-[69rem]:col-span-5',
|
||||
6: 'col-span-12 @min-[57.5rem]:col-span-6',
|
||||
7: 'col-span-12 @min-[69rem]:col-span-7',
|
||||
8: 'col-span-12 @min-[86.5rem]:col-span-8',
|
||||
9: 'col-span-12 @min-[116rem]:col-span-9',
|
||||
10: 'col-span-12 @min-[174.5rem]:col-span-10',
|
||||
11: 'col-span-12',
|
||||
12: 'col-span-12',
|
||||
},
|
||||
32: {
|
||||
1: 'col-span-12 @min-[65.5rem]:col-span-6 @min-[98.5rem]:col-span-4 @min-[132rem]:col-span-3 @min-[198.5rem]:col-span-2',
|
||||
2: 'col-span-12 @min-[65.5rem]:col-span-6 @min-[98.5rem]:col-span-4 @min-[132rem]:col-span-3 @min-[198.5rem]:col-span-2',
|
||||
3: 'col-span-12 @min-[65.5rem]:col-span-6 @min-[98.5rem]:col-span-4 @min-[132rem]:col-span-3',
|
||||
4: 'col-span-12 @min-[65.5rem]:col-span-6 @min-[98.5rem]:col-span-4',
|
||||
5: 'col-span-12 @min-[65.5rem]:col-span-6 @min-[79rem]:col-span-5',
|
||||
6: 'col-span-12 @min-[65.5rem]:col-span-6',
|
||||
7: 'col-span-12 @min-[79rem]:col-span-7',
|
||||
8: 'col-span-12 @min-[98.5rem]:col-span-8',
|
||||
9: 'col-span-12 @min-[132rem]:col-span-9',
|
||||
10: 'col-span-12 @min-[198.5rem]:col-span-10',
|
||||
11: 'col-span-12',
|
||||
12: 'col-span-12',
|
||||
},
|
||||
40: {
|
||||
1: 'col-span-12 @min-[81.5rem]:col-span-6 @min-[122.5rem]:col-span-4 @min-[164rem]:col-span-3',
|
||||
2: 'col-span-12 @min-[81.5rem]:col-span-6 @min-[122.5rem]:col-span-4 @min-[164rem]:col-span-3',
|
||||
3: 'col-span-12 @min-[81.5rem]:col-span-6 @min-[122.5rem]:col-span-4 @min-[164rem]:col-span-3',
|
||||
4: 'col-span-12 @min-[81.5rem]:col-span-6 @min-[122.5rem]:col-span-4',
|
||||
5: 'col-span-12 @min-[81.5rem]:col-span-6 @min-[98rem]:col-span-5',
|
||||
6: 'col-span-12 @min-[81.5rem]:col-span-6',
|
||||
7: 'col-span-12 @min-[98rem]:col-span-7',
|
||||
8: 'col-span-12 @min-[122.5rem]:col-span-8',
|
||||
9: 'col-span-12 @min-[164rem]:col-span-9',
|
||||
10: 'col-span-12',
|
||||
11: 'col-span-12',
|
||||
12: 'col-span-12',
|
||||
},
|
||||
48: {
|
||||
1: 'col-span-12 @min-[97.5rem]:col-span-6 @min-[146.5rem]:col-span-4 @min-[196rem]:col-span-3',
|
||||
2: 'col-span-12 @min-[97.5rem]:col-span-6 @min-[146.5rem]:col-span-4 @min-[196rem]:col-span-3',
|
||||
3: 'col-span-12 @min-[97.5rem]:col-span-6 @min-[146.5rem]:col-span-4 @min-[196rem]:col-span-3',
|
||||
4: 'col-span-12 @min-[97.5rem]:col-span-6 @min-[146.5rem]:col-span-4',
|
||||
5: 'col-span-12 @min-[97.5rem]:col-span-6 @min-[117rem]:col-span-5',
|
||||
6: 'col-span-12 @min-[97.5rem]:col-span-6',
|
||||
7: 'col-span-12 @min-[117rem]:col-span-7',
|
||||
8: 'col-span-12 @min-[146.5rem]:col-span-8',
|
||||
9: 'col-span-12 @min-[196rem]:col-span-9',
|
||||
10: 'col-span-12',
|
||||
11: 'col-span-12',
|
||||
12: 'col-span-12',
|
||||
},
|
||||
64: {
|
||||
1: 'col-span-12 @min-[129.5rem]:col-span-6 @min-[194.5rem]:col-span-4',
|
||||
2: 'col-span-12 @min-[129.5rem]:col-span-6 @min-[194.5rem]:col-span-4',
|
||||
3: 'col-span-12 @min-[129.5rem]:col-span-6 @min-[194.5rem]:col-span-4',
|
||||
4: 'col-span-12 @min-[129.5rem]:col-span-6 @min-[194.5rem]:col-span-4',
|
||||
5: 'col-span-12 @min-[129.5rem]:col-span-6 @min-[155.5rem]:col-span-5',
|
||||
6: 'col-span-12 @min-[129.5rem]:col-span-6',
|
||||
7: 'col-span-12 @min-[155.5rem]:col-span-7',
|
||||
8: 'col-span-12 @min-[194.5rem]:col-span-8',
|
||||
9: 'col-span-12',
|
||||
10: 'col-span-12',
|
||||
11: 'col-span-12',
|
||||
12: 'col-span-12',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared minimum widget width in rem: a number (rem) or a string with a `rem` or `px`
|
||||
* unit (16px = 1rem).
|
||||
*
|
||||
* @param value - The declared value (`defaultValues.gridMinWidth`).
|
||||
* @returns The width in rem, or undefined when the value is not a positive length.
|
||||
*/
|
||||
export function gridMinWidthRem(value: unknown): number | undefined {
|
||||
let rem = NaN
|
||||
if (typeof value === 'number') rem = value
|
||||
else if (typeof value === 'string') {
|
||||
const match = /^\s*(\d*\.?\d+)\s*(rem|px)?\s*$/i.exec(value)
|
||||
if (match) rem = match[2]?.toLowerCase() === 'px' ? Number(match[1]) / 16 : Number(match[1])
|
||||
}
|
||||
return Number.isFinite(rem) && rem > 0 ? rem : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Classes that size an item of a 12-column `@container` grid so it only shares a row when it
|
||||
* gets at least `minRem` (snapped up to a {@link MIN_WIDTH_TIERS} tier); an item wider than
|
||||
* the last tier always takes the full width.
|
||||
*
|
||||
* @param span - Declared width in twelfths (`gridColumns`); full width when unset.
|
||||
* @param minRem - The item's minimum width in rem.
|
||||
* @returns The classes (always including `min-w-0`).
|
||||
*/
|
||||
export function minWidthSpanClasses(span: number | undefined | null, minRem: number): string {
|
||||
const tier = MIN_WIDTH_TIERS.find((candidate) => candidate >= minRem)
|
||||
return `${tier ? MIN_WIDTH_SPANS[tier][normalizedSpan(span)] : 'col-span-12'} min-w-0`
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared grid profile (`defaultValues.gridProfile`): `table` is an alias of `cards`.
|
||||
*
|
||||
* @param value - The declared value.
|
||||
* @returns The profile, or undefined when the value is not one.
|
||||
*/
|
||||
export function gridProfileOf(value: unknown): GridItemProfile | undefined {
|
||||
if (value === 'table' || value === 'cards') return 'cards'
|
||||
return value === 'widgets' || value === 'fields' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared maximum width as CSS: a number is rem, a string may be a length in `rem`, `px`,
|
||||
* `ch`, `em` or `%`; `none` (or 0) lifts the cap.
|
||||
*
|
||||
* @param value - The declared value.
|
||||
* @param fallback - The width when nothing usable is declared.
|
||||
* @returns The CSS `max-width`, or undefined for no cap.
|
||||
*/
|
||||
export function cssMaxWidth(value: unknown, fallback?: string): string | undefined {
|
||||
if (value === 'none' || value === 0 || value === '0') return undefined
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return `${value}rem`
|
||||
if (typeof value === 'string' && /^\s*\d*\.?\d+(rem|px|ch|em|%)\s*$/i.test(value)) return value.trim().toLowerCase()
|
||||
return fallback
|
||||
}
|
||||
@@ -35,6 +35,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* QqqTableWidget card layout: a table widget narrower than 30rem (or than its declared column
|
||||
widths need) shows each row as a card of "label: value" lines, never a sideways scroll.
|
||||
Unlayered, so it wins over the table's utility classes. */
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table,
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table :is(tbody, tfoot, tr) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
/* headers stay in the accessibility tree (screen-reader only); each value is labelled visually */
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table tr {
|
||||
position: static;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table td {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.75rem;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
/* the expand/collapse control has no label: it sits at the card's end */
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table td:not([data-label]) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table td:not([data-label])::before {
|
||||
content: none;
|
||||
}
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table td:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Register semantic colors with Tailwind v4's utility system */
|
||||
@theme {
|
||||
/* Surface colors */
|
||||
|
||||
@@ -143,12 +143,18 @@ test.describe('sample widgets dashboard', () => {
|
||||
const total = payload.rows.at(-1)
|
||||
await expect(page.locator('[data-qqq-id="table-total-row-SampleTableWidget"]')).toHaveText(accessors.map((key: string) => total[key]).join(''))
|
||||
await expect(table.locator('tbody tr').first().locator('td').nth(1)).toHaveClass(/text-right/)
|
||||
// a table wider than its card scrolls inside the card, never the page
|
||||
// the table never scrolls the page: a narrow widget shows its rows as cards (no sideways
|
||||
// scroll at all), a wider one keeps the table, which scrolls inside the card if it must
|
||||
await expectNoHorizontalScroll(page)
|
||||
const card = await widget(page, 'SampleTableWidget').boundingBox()
|
||||
const scroller = await table.locator('xpath=..').boundingBox()
|
||||
expect(scroller!.x + scroller!.width).toBeLessThanOrEqual(card!.x + card!.width + 1)
|
||||
expect(await table.locator('xpath=..').evaluate((node) => getComputedStyle(node).overflowX)).toBe('auto')
|
||||
const layout = await page.locator('[data-qqq-id="table-widget-SampleTableWidget"]').getAttribute('data-qqq-table-layout')
|
||||
expect(await table.locator('xpath=..').evaluate((node) => getComputedStyle(node).overflowX)).toBe(layout === 'cards' ? 'hidden' : 'auto')
|
||||
if (layout === 'cards') {
|
||||
expect(await table.locator('xpath=..').evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true)
|
||||
expect(await table.locator('tbody td').first().getAttribute('data-label')).toBe(payload.columns[0].header)
|
||||
}
|
||||
})
|
||||
|
||||
test('[WID-017] stacked bar chart stacks each dataset with its colors and legend @mobile', async ({ page, backend, diagnostics }) => {
|
||||
|
||||
@@ -68,23 +68,29 @@ test.describe('table and chart extras', () => {
|
||||
// a zero total shows no total (Material ImageCell)
|
||||
await expect(table.locator('[data-qqq-id="table-row-accTableCells-1"] td').nth(3)).toHaveText('Owned product')
|
||||
|
||||
// fr widths share what the fixed 180px column (and the 60px expander) leave: Name (2fr) is twice Count (1fr)
|
||||
const headers = table.locator('thead th')
|
||||
const widths = await headers.evaluateAll((cells) => cells.map((cell) => cell.getBoundingClientRect().width))
|
||||
expect(Math.abs(widths[0] - 2 * widths[1])).toBeLessThanOrEqual(2)
|
||||
expect(Math.abs(widths[1] - widths[2])).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(widths[3] - 180)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(widths[5] - 60)).toBeLessThanOrEqual(1)
|
||||
// a widget narrower than its declared columns need (180px + 60px + 4 x 6rem) shows rows as
|
||||
// labelled cards: no column widths or hover header help there, each value carries its label
|
||||
if (await card.locator('[data-qqq-table-layout="cards"]').count()) {
|
||||
expect(await first.evaluateAll((cells) => cells.map((cell) => cell.getAttribute('data-label')))).toEqual(['Name', 'Count', 'Status', 'Product', 'Detail', null])
|
||||
} else {
|
||||
// fr widths share what the fixed 180px column (and the 60px expander) leave: Name (2fr) is twice Count (1fr)
|
||||
const headers = table.locator('thead th')
|
||||
const widths = await headers.evaluateAll((cells) => cells.map((cell) => cell.getBoundingClientRect().width))
|
||||
expect(Math.abs(widths[0] - 2 * widths[1])).toBeLessThanOrEqual(2)
|
||||
expect(Math.abs(widths[1] - widths[2])).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(widths[3] - 180)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(widths[5] - 60)).toBeLessThanOrEqual(1)
|
||||
|
||||
// the columnHeader=name help slot on the Name header
|
||||
const help = byId(page, 'table-header-help-accTableCells-name')
|
||||
await expect(help).toBeHidden()
|
||||
await showTooltip(page, headers.nth(0).locator('[data-tooltip-trigger]'))
|
||||
await expect(help).toBeVisible()
|
||||
await expect(help).toHaveText('Owned name column help')
|
||||
await expect(help.locator('b')).toHaveText('name')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(headers.nth(1).getByRole('tooltip', { includeHidden: true })).toHaveCount(0)
|
||||
// the columnHeader=name help slot on the Name header
|
||||
const help = byId(page, 'table-header-help-accTableCells-name')
|
||||
await expect(help).toBeHidden()
|
||||
await showTooltip(page, headers.nth(0).locator('[data-tooltip-trigger]'))
|
||||
await expect(help).toBeVisible()
|
||||
await expect(help).toHaveText('Owned name column help')
|
||||
await expect(help.locator('b')).toHaveText('name')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(headers.nth(1).getByRole('tooltip', { includeHidden: true })).toHaveCount(0)
|
||||
}
|
||||
|
||||
// sub-rows expand and collapse, nested rows are shaded
|
||||
expect((await bodyRows(table)).map((row) => row[0])).toEqual(['Owned parent↗', 'Owned second'])
|
||||
@@ -140,13 +146,19 @@ test.describe('table and chart extras', () => {
|
||||
await size.selectOption('25')
|
||||
expect(await names()).toEqual(range(1, 25))
|
||||
|
||||
// the header stays at the top while the fixed-height body scrolls
|
||||
// the fixed-height body scrolls; in table layout the header stays at its top, in the
|
||||
// narrow-widget card layout each value carries its own label instead
|
||||
const scroller = table.locator('xpath=..')
|
||||
await scroller.evaluate((node) => { node.scrollTop = 300 })
|
||||
await expect.poll(() => scroller.evaluate((node) => node.scrollTop)).toBeGreaterThan(200)
|
||||
const header = await table.locator('thead th').first().boundingBox()
|
||||
const box = await scroller.boundingBox()
|
||||
expect(Math.abs(header!.y - box!.y)).toBeLessThanOrEqual(1)
|
||||
if (await card.locator('[data-qqq-table-layout="cards"]').count()) {
|
||||
await expect(table.locator('tbody td').first()).toHaveAttribute('data-label', 'Row')
|
||||
expect(await scroller.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true)
|
||||
} else {
|
||||
const header = await table.locator('thead th').first().boundingBox()
|
||||
const box = await scroller.boundingBox()
|
||||
expect(Math.abs(header!.y - box!.y)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
await expectTouchReady(page, card)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// E2E: a dashboard of table and HTML widgets stays readable from a phone to an ultra-wide
|
||||
// screen: widgets pair only when each gets its declared minimum width, tables become stacked
|
||||
// cards when narrow, table content stops growing at its max width, and nothing scrolls sideways.
|
||||
// Set QQQ_E2E_SCREENSHOT_DIR to keep a full-page screenshot per viewport.
|
||||
|
||||
import path from 'node:path'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
|
||||
const REM = 16
|
||||
/** gridMinWidth of the paired tables, and the grid width from which two of them share a row. */
|
||||
const PAIR_MIN_REM = 28
|
||||
const PAIR_FROM_PX = 57.5 * REM
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: '390', width: 390, height: 844 },
|
||||
{ name: '768', width: 768, height: 1024 },
|
||||
{ name: '1024-sidebar', width: 1024, height: 768 },
|
||||
{ name: '1440', width: 1440, height: 900 },
|
||||
{ name: '1920', width: 1920, height: 1080 },
|
||||
{ name: '3200', width: 3200, height: 1800 },
|
||||
]
|
||||
|
||||
const TABLE = {
|
||||
type: 'table',
|
||||
columns: [
|
||||
{ header: 'Order', accessor: 'order' },
|
||||
{ header: 'Customer', accessor: 'customer' },
|
||||
{ header: 'Status', accessor: 'status' },
|
||||
{ header: 'Total', accessor: 'total', align: 'right' },
|
||||
],
|
||||
rows: Array.from({ length: 5 }, (_, index) => ({
|
||||
order: `SO-10${index}`,
|
||||
customer: ['Acme Corporation', 'Globex', 'Initech Holdings', 'Umbrella', 'Hooli'][index],
|
||||
status: index % 2 ? 'Shipped' : 'Awaiting fulfilment',
|
||||
total: 1250 * (index + 1),
|
||||
})),
|
||||
}
|
||||
|
||||
const WIDE_TABLE = {
|
||||
type: 'table',
|
||||
columns: [
|
||||
{ header: 'Day', accessor: 'day' },
|
||||
{ header: 'Visits', accessor: 'visits', align: 'right' },
|
||||
{ header: 'Signups', accessor: 'signups', align: 'right' },
|
||||
],
|
||||
rows: [{ day: 'Monday', visits: 1204, signups: 31 }, { day: 'Tuesday', visits: 1388, signups: 42 }],
|
||||
}
|
||||
|
||||
const DECLARED_TABLE = {
|
||||
type: 'table',
|
||||
columns: [
|
||||
{ header: 'Sku', accessor: 'sku', width: '200px' },
|
||||
{ header: 'Description', accessor: 'description', width: '2fr' },
|
||||
{ header: 'Warehouse', accessor: 'warehouse', width: '1fr' },
|
||||
{ header: 'On hand', accessor: 'onHand', width: '120px', align: 'right' },
|
||||
],
|
||||
rows: [{ sku: 'WIDGET-0001-BLUE-LARGE', description: 'A very long product description that must wrap', warehouse: 'Main', onHand: 42 }],
|
||||
}
|
||||
|
||||
const HTML = { type: 'html', html: '<p>Summary text for the dashboard, long enough to wrap onto more than one line in a narrow column.</p>' }
|
||||
|
||||
const WIDGETS = {
|
||||
ordersTable: { name: 'ordersTable', label: 'Open Orders', type: 'table', gridColumns: 6, defaultValues: { gridMinWidth: PAIR_MIN_REM } },
|
||||
returnsTable: { name: 'returnsTable', label: 'Returns', type: 'table', gridColumns: 6, defaultValues: { gridMinWidth: PAIR_MIN_REM } },
|
||||
notesHtml: { name: 'notesHtml', label: 'Notes', type: 'html', gridColumns: 6 },
|
||||
helpHtml: { name: 'helpHtml', label: 'Help', type: 'html', gridColumns: 6 },
|
||||
trafficTable: { name: 'trafficTable', label: 'Traffic', type: 'table', gridColumns: 12 },
|
||||
stockTable: { name: 'stockTable', label: 'Stock', type: 'table', gridColumns: 6 },
|
||||
}
|
||||
const PAYLOADS: Record<string, unknown> = {
|
||||
ordersTable: TABLE, returnsTable: TABLE, notesHtml: HTML, helpHtml: HTML, trafficTable: WIDE_TABLE, stockTable: DECLARED_TABLE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds two dashboards to the mocked backend: `widthLab` (uncapped) and `cappedLab`, whose app
|
||||
* caps the page with `supplementalAppMetaData.dashboardLayout.maxContentWidth`.
|
||||
*
|
||||
* @param page - The page.
|
||||
*/
|
||||
async function setupDashboard(page: Page) {
|
||||
await setupApiMocks(page)
|
||||
const app = (name: string, label: string, extra: Record<string, unknown> = {}) => ({
|
||||
name, label, iconName: 'dashboard', widgets: Object.keys(WIDGETS), children: [], sections: [], ...extra,
|
||||
})
|
||||
const metaData = {
|
||||
...METADATA,
|
||||
apps: {
|
||||
...METADATA.apps,
|
||||
widthLab: app('widthLab', 'Width Lab'),
|
||||
cappedLab: app('cappedLab', 'Capped Lab', { supplementalAppMetaData: { dashboardLayout: { maxContentWidth: 120 } } }),
|
||||
},
|
||||
appTree: [
|
||||
...METADATA.appTree,
|
||||
{ name: 'widthLab', label: 'Width Lab', type: 'APP', iconName: 'dashboard', children: [] },
|
||||
{ name: 'cappedLab', label: 'Capped Lab', type: 'APP', iconName: 'dashboard', children: [] },
|
||||
],
|
||||
widgets: Object.fromEntries(Object.entries(WIDGETS).map(([name, widget]) => [name, { isCard: true, hasPermission: true, ...widget }])),
|
||||
}
|
||||
await page.route('**/qqq/v1/metaData**', (route) => {
|
||||
if (new URL(route.request().url()).pathname.endsWith('/metaData/authentication')) return route.fallback()
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(metaData) })
|
||||
})
|
||||
await page.route('**/qqq/v1/widget/**', (route) => {
|
||||
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop() ?? '')
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(PAYLOADS[name] ?? {}) })
|
||||
})
|
||||
}
|
||||
|
||||
/** Box of an element (by data-qqq-id). */
|
||||
async function box(page: Page, qqqId: string) {
|
||||
const handle = page.locator(`[data-qqq-id="${qqqId}"]`)
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds, qqqId).not.toBeNull()
|
||||
return bounds!
|
||||
}
|
||||
|
||||
/** Widths that overflow sideways: the page, and any table widget's scroll area. */
|
||||
async function sidewaysOverflow(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const problems: string[] = []
|
||||
const root = document.scrollingElement ?? document.documentElement
|
||||
if (root.scrollWidth > root.clientWidth + 1) problems.push(`page ${root.scrollWidth} > ${root.clientWidth}`)
|
||||
for (const element of Array.from(document.querySelectorAll('main, main *'))) {
|
||||
const style = getComputedStyle(element)
|
||||
const scrolls = /auto|scroll/.test(style.overflowX)
|
||||
if (element.tagName === 'MAIN' || scrolls) {
|
||||
if (element.scrollWidth > element.clientWidth + 1) {
|
||||
const id = element.getAttribute('data-qqq-id') ?? element.closest('[data-qqq-id]')?.getAttribute('data-qqq-id') ?? element.tagName
|
||||
problems.push(`${id} ${element.scrollWidth} > ${element.clientWidth}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Dashboard widths', () => {
|
||||
for (const viewport of VIEWPORTS) {
|
||||
test(`readable at ${viewport.name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height })
|
||||
await setupDashboard(page)
|
||||
await page.goto('/app/widthLab')
|
||||
for (const name of Object.keys(WIDGETS)) {
|
||||
await expect(page.locator(`[data-qqq-id="widget-grid-item-${name}"]`)).toBeVisible()
|
||||
}
|
||||
await expect(page.locator('[data-qqq-id="table-widget-ordersTable"] tbody tr')).toHaveCount(5)
|
||||
await expect(page.locator('[data-qqq-id="table-widget-stockTable"] tbody tr')).toHaveCount(1)
|
||||
|
||||
// never sideways scroll, at any width
|
||||
expect(await sidewaysOverflow(page)).toEqual([])
|
||||
|
||||
// the 28rem tables share a row only when each gets 28rem
|
||||
const grid = await box(page, 'widget-grid')
|
||||
const orders = await box(page, 'widget-grid-item-ordersTable')
|
||||
const returns = await box(page, 'widget-grid-item-returnsTable')
|
||||
const paired = Math.abs(orders.y - returns.y) < 1
|
||||
expect(paired).toBe(grid.width >= PAIR_FROM_PX)
|
||||
expect(orders.width).toBeGreaterThanOrEqual(Math.min(grid.width, PAIR_MIN_REM * REM) - 1)
|
||||
|
||||
// a table widget narrower than 30rem or its declared columns shows cards, never a cramped table
|
||||
for (const name of ['ordersTable', 'stockTable']) {
|
||||
const table = page.locator(`[data-qqq-id="table-widget-${name}"]`)
|
||||
const width = (await table.boundingBox())!.width
|
||||
const needs = name === 'stockTable' ? Math.max(30 * REM, 200 + 2 * 6 * REM + 120) : 30 * REM
|
||||
await expect(table).toHaveAttribute('data-qqq-table-layout', width < needs ? 'cards' : 'table')
|
||||
}
|
||||
|
||||
// the full-width table's content stops at 96rem
|
||||
expect((await box(page, 'table-widget-trafficTable')).width).toBeLessThanOrEqual(96 * REM + 1)
|
||||
|
||||
const dir = process.env.QQQ_E2E_SCREENSHOT_DIR
|
||||
if (dir) await page.screenshot({ path: path.join(dir, `dashboard-${viewport.name}.png`), fullPage: true })
|
||||
})
|
||||
}
|
||||
|
||||
test('a phone shows table rows as labelled cards', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await setupDashboard(page)
|
||||
await page.goto('/app/widthLab')
|
||||
const table = page.locator('[data-qqq-id="table-widget-ordersTable"]')
|
||||
await expect(table).toHaveAttribute('data-qqq-table-layout', 'cards')
|
||||
// headers are screen-reader only: still column headers, but clipped to 1px
|
||||
await expect(table.getByRole('columnheader')).toHaveText(['Order', 'Customer', 'Status', 'Total'])
|
||||
expect((await table.locator('thead').boundingBox())!.height).toBeLessThanOrEqual(1)
|
||||
const label = await table.locator('tbody td').first().evaluate((cell) => getComputedStyle(cell, '::before').content)
|
||||
expect(label).toBe('"Order"')
|
||||
})
|
||||
|
||||
test('an app can cap the dashboard width on an ultra-wide screen', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 3200, height: 1800 })
|
||||
await setupDashboard(page)
|
||||
await page.goto('/app/cappedLab')
|
||||
await expect(page.locator('[data-qqq-id="widget-grid-item-ordersTable"]')).toBeVisible()
|
||||
const home = await box(page, 'app-home-cappedLab')
|
||||
expect(home.width).toBeLessThanOrEqual(120 * REM + 1)
|
||||
const dir = process.env.QQQ_E2E_SCREENSHOT_DIR
|
||||
if (dir) await page.screenshot({ path: path.join(dir, 'dashboard-3200-capped.png'), fullPage: true })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user