feat(widgets): readable dashboard widths from phone to ultra-wide
- Per-widget minimum width: widgetColumnClasses honors defaultValues.gridMinWidth
(rem or px; snapped up to 16-64rem tiers) with container-query thresholds that
give the widget, and a neighbour of the same minimum, at least that width; and
defaultValues.gridProfile ('widgets' | 'cards' | 'table' | 'fields') to pick a
ladder. gridCols:sizeClass:* still wins.
- Ultra-wide: table and multi-table widget content stops at 96rem
(defaultValues.tableMaxWidth overrides, 'none' lifts it); an app can cap the
dashboard page with supplementalAppMetaData.dashboardLayout.maxContentWidth.
- QqqTableWidget card layout: narrower than 30rem, or than its declared column
widths need, rows render as stacked label: value cards with no forced min
width, fixed columns or sideways scroll.
- Unit tests (threshold table regenerated from the formula) and a Playwright
dashboard spec at 390/768/1024/1440/1920/3200.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
77eef1c816
commit
a163dc53b1
13 files changed
+754
-14
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:
|
||||
|
||||
@@ -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,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
[data-qqq-table-layout="cards"] .qqq-table-widget-table thead {
|
||||
display: none;
|
||||
}
|
||||
[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 */
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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')
|
||||
await expect(table.locator('thead')).toBeHidden()
|
||||
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