@startsimpli/ui 0.4.92 → 0.4.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/components/dashboard/DashboardSection.tsx +36 -5
- package/src/components/dashboard/MetricCard.tsx +4 -1
- package/src/components/dashboard/StatTile.tsx +20 -1
- package/src/components/states/Absence.tsx +156 -0
- package/src/components/states/Caveat.tsx +167 -0
- package/src/components/states/EmptyState.tsx +39 -10
- package/src/components/states/Footnote.tsx +171 -0
- package/src/components/states/__tests__/caveat-and-absence.test.tsx +662 -0
- package/src/components/states/index.ts +7 -1
- package/src/components/ui/card.tsx +37 -6
- package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +62 -5
- package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +22 -0
- package/src/components/unified-table/__tests__/utils/border-tokens.test.ts +61 -0
- package/src/components/unified-table/components/BulkActionBar/index.tsx +1 -1
- package/src/components/unified-table/components/DataTableCore/index.tsx +1 -1
- package/src/components/unified-table/components/MobileView/Card.tsx +1 -1
- package/src/components/unified-table/components/TableFilters/index.tsx +2 -2
- package/src/components/unified-table/components/TablePagination/index.tsx +19 -5
- package/src/components/unified-table/hooks/usePagination.ts +12 -0
- package/src/components/unified-table/utils/themes.ts +8 -5
- package/src/theme/__tests__/type-scale.test.tsx +145 -0
- package/src/theme/contract.ts +5 -0
- package/src/theme/index.ts +10 -0
- package/src/theme/type-scale.ts +119 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
2
|
|
|
3
3
|
import { cn } from "../../lib/utils"
|
|
4
|
+
import { Caveat } from "../states/Caveat"
|
|
5
|
+
import { Footnote } from "../states/Footnote"
|
|
4
6
|
|
|
5
7
|
const Card = React.forwardRef<
|
|
6
8
|
HTMLDivElement,
|
|
@@ -17,12 +19,41 @@ const Card = React.forwardRef<
|
|
|
17
19
|
))
|
|
18
20
|
Card.displayName = "Card"
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
23
|
+
/**
|
|
24
|
+
* A qualification of what this card shows. Collapses to a marker at the end
|
|
25
|
+
* of the header and expands on demand — the card's canonical caveat slot, so
|
|
26
|
+
* apps stop putting the caveat in the body at the weight of the data.
|
|
27
|
+
*/
|
|
28
|
+
caveat?: React.ReactNode
|
|
29
|
+
/**
|
|
30
|
+
* A caveat that belongs to the whole section. Inside a `FootnoteScope` it is
|
|
31
|
+
* printed once beneath the section rather than once per card.
|
|
32
|
+
*/
|
|
33
|
+
note?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
|
|
37
|
+
({ className, caveat, note, children, ...props }, ref) => {
|
|
38
|
+
const hasCaveat = Boolean(caveat || note)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props}>
|
|
42
|
+
{hasCaveat ? (
|
|
43
|
+
<div className="flex items-start justify-between gap-2">
|
|
44
|
+
<div className="flex min-w-0 flex-col space-y-1.5">{children}</div>
|
|
45
|
+
<span className="flex shrink-0 items-center">
|
|
46
|
+
{caveat ? <Caveat>{caveat}</Caveat> : null}
|
|
47
|
+
{note ? <Footnote note={note} /> : null}
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
) : (
|
|
51
|
+
children
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
)
|
|
26
57
|
CardHeader.displayName = "CardHeader"
|
|
27
58
|
|
|
28
59
|
const CardTitle = React.forwardRef<
|
|
@@ -316,18 +316,75 @@ describe('TablePagination', () => {
|
|
|
316
316
|
})
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
319
|
+
// An empty table has nothing to count. Every number on this line is either
|
|
320
|
+
// wrong or noise, so the line becomes a sentence instead of arithmetic.
|
|
321
|
+
describe('Empty result set', () => {
|
|
322
|
+
const emptyPagination = (overrides?: Partial<UsePaginationReturn>) =>
|
|
323
|
+
createMockPagination({
|
|
322
324
|
totalCount: 0,
|
|
323
325
|
totalPages: 1,
|
|
326
|
+
canGoNext: false,
|
|
327
|
+
canGoPrevious: false,
|
|
324
328
|
getDisplayRange: jest.fn(() => ({ start: 0, end: 0 })),
|
|
329
|
+
...overrides,
|
|
325
330
|
})
|
|
326
|
-
render(<TablePagination pagination={pagination} />)
|
|
327
331
|
|
|
328
|
-
|
|
332
|
+
it('states that there is nothing rather than printing a range', () => {
|
|
333
|
+
const { container } = render(
|
|
334
|
+
<TablePagination pagination={emptyPagination()} totalFilteredCount={0} />
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const countLine = container.querySelector('.text-muted-foreground')
|
|
338
|
+
expect(countLine?.textContent).toBe('No items')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('never prints a range whose start is past its end', () => {
|
|
342
|
+
const { container } = render(
|
|
343
|
+
<TablePagination
|
|
344
|
+
pagination={emptyPagination({
|
|
345
|
+
// What the unfixed hook hands the component on an empty table.
|
|
346
|
+
getDisplayRange: jest.fn(() => ({ start: 1, end: 0 })),
|
|
347
|
+
})}
|
|
348
|
+
/>
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
expect(container.textContent).not.toContain('1-0')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('does not leak a stray 0 out of the filtered-count guard', () => {
|
|
355
|
+
// `{count && <span/>}` renders the NUMBER 0 when count is 0 — that is the
|
|
356
|
+
// second 0 in "of 00".
|
|
357
|
+
const { container } = render(
|
|
358
|
+
<TablePagination pagination={emptyPagination()} totalFilteredCount={0} />
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
expect(container.textContent).not.toMatch(/00/)
|
|
362
|
+
expect(container.textContent).toBe('No items')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('does not leak a stray 0 out of the select-all guard', () => {
|
|
366
|
+
const { container } = render(
|
|
367
|
+
<TablePagination
|
|
368
|
+
pagination={emptyPagination()}
|
|
369
|
+
showSelectAllPrompt
|
|
370
|
+
onSelectAllPages={jest.fn()}
|
|
371
|
+
totalFilteredCount={0}
|
|
372
|
+
/>
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
expect(container.textContent).toBe('No items')
|
|
376
|
+
expect(screen.queryByText(/Select all/)).not.toBeInTheDocument()
|
|
329
377
|
})
|
|
330
378
|
|
|
379
|
+
it('hides page controls when there is nothing to page through', () => {
|
|
380
|
+
render(<TablePagination pagination={emptyPagination()} />)
|
|
381
|
+
|
|
382
|
+
expect(screen.queryByTitle('First page')).not.toBeInTheDocument()
|
|
383
|
+
expect(screen.queryByTitle('Next page')).not.toBeInTheDocument()
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
describe('Edge Cases', () => {
|
|
331
388
|
it('should handle single item', () => {
|
|
332
389
|
const pagination = createMockPagination({
|
|
333
390
|
totalCount: 1,
|
|
@@ -341,6 +341,28 @@ describe('usePagination', () => {
|
|
|
341
341
|
const range = result.current.getDisplayRange()
|
|
342
342
|
expect(range).toEqual({ start: 51, end: 75 })
|
|
343
343
|
})
|
|
344
|
+
|
|
345
|
+
it('should report an empty range when there are no rows at all', () => {
|
|
346
|
+
const { result } = renderHook(() =>
|
|
347
|
+
usePagination({ ...defaultProps, totalCount: 0 })
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
// A range that begins at 1 and ends at 0 describes nothing; it is the
|
|
351
|
+
// arithmetic leaking out. Nothing shown means nothing on both ends.
|
|
352
|
+
const range = result.current.getDisplayRange()
|
|
353
|
+
expect(range).toEqual({ start: 0, end: 0 })
|
|
354
|
+
expect(range.start).not.toBeGreaterThan(range.end)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('should report an empty range when the page sits past the last row', () => {
|
|
358
|
+
// Server-side counts shrink under a page that is already open (rule 8:
|
|
359
|
+
// the count comes from the server, the client never recounts).
|
|
360
|
+
const { result } = renderHook(() =>
|
|
361
|
+
usePagination({ ...defaultProps, totalCount: 10, initialPage: 2 })
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
expect(result.current.getDisplayRange()).toEqual({ start: 0, end: 0 })
|
|
365
|
+
})
|
|
344
366
|
})
|
|
345
367
|
|
|
346
368
|
describe('Server-Side Pagination', () => {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every border in the table names its colour (startsim-768w.35.2.4).
|
|
3
|
+
*
|
|
4
|
+
* In Tailwind v4 a bare `border` / `border-b` sets a width and leaves the
|
|
5
|
+
* colour at `currentColor`, so the rule is painted in the TEXT colour of
|
|
6
|
+
* whatever it sits in. It is quiet on a white page — the rows just look heavier
|
|
7
|
+
* than intended — and loud on a dark tenant, where every row separator turns
|
|
8
|
+
* near-white. Browser-confirmed on both surfaces before this guard existed.
|
|
9
|
+
*
|
|
10
|
+
* The fix is always the same: say `border-border` (or a token shade like
|
|
11
|
+
* `border-border/50`) next to the width utility.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs'
|
|
15
|
+
import * as path from 'path'
|
|
16
|
+
|
|
17
|
+
const TABLE_SRC = path.resolve(__dirname, '../..')
|
|
18
|
+
|
|
19
|
+
/** `border`, `border-t`, `border-x` — width with no colour. */
|
|
20
|
+
const BARE_BORDER = /^border(-[trblxy])?$/
|
|
21
|
+
/** Anything that names a colour: `border-border`, `border-primary`, `border-[#fff]`. */
|
|
22
|
+
const NAMED_COLOR = /^([a-z-]+:)*border-(?!\d|[trblxy]$)/
|
|
23
|
+
|
|
24
|
+
function sourceFiles(dir: string, acc: string[] = []): string[] {
|
|
25
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
26
|
+
const full = path.join(dir, entry.name)
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
if (entry.name === '__tests__' || entry.name === 'node_modules') continue
|
|
29
|
+
sourceFiles(full, acc)
|
|
30
|
+
} else if (/\.tsx?$/.test(entry.name)) {
|
|
31
|
+
acc.push(full)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return acc
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function offenders(): string[] {
|
|
38
|
+
const found: string[] = []
|
|
39
|
+
for (const file of sourceFiles(TABLE_SRC)) {
|
|
40
|
+
fs.readFileSync(file, 'utf8')
|
|
41
|
+
.split('\n')
|
|
42
|
+
.forEach((rawLine, i) => {
|
|
43
|
+
// Prose in a doc comment may legitimately name the banned class.
|
|
44
|
+
const line = rawLine.replace(/^\s*(\/\/|\/\*|\*).*$/, '')
|
|
45
|
+
for (const quoted of line.match(/'[^']*'|"[^"]*"|`[^`]*`/g) ?? []) {
|
|
46
|
+
const tokens = quoted.slice(1, -1).split(/\s+/)
|
|
47
|
+
const bare = tokens.filter((t) => BARE_BORDER.test(t))
|
|
48
|
+
if (!bare.length) continue
|
|
49
|
+
if (tokens.some((t) => NAMED_COLOR.test(t))) continue
|
|
50
|
+
found.push(`${path.relative(TABLE_SRC, file)}:${i + 1} ${bare.join(' ')}`)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
return found
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('unified-table borders are theme-coloured, not currentColor', () => {
|
|
58
|
+
it('no border utility ships without a colour token beside it', () => {
|
|
59
|
+
expect(offenders()).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -54,7 +54,7 @@ export function BulkActionBar({
|
|
|
54
54
|
)}
|
|
55
55
|
aria-hidden={!isVisible}
|
|
56
56
|
>
|
|
57
|
-
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border">
|
|
57
|
+
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border">
|
|
58
58
|
{/* Selection summary */}
|
|
59
59
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
60
60
|
{selectAllPages ? (
|
|
@@ -330,7 +330,7 @@ export function DataTableCore<TData>({
|
|
|
330
330
|
return (
|
|
331
331
|
// overflow-x-auto: a wide table scrolls horizontally inside its own border
|
|
332
332
|
// instead of overflowing the page to the right (startsim column-overflow).
|
|
333
|
-
<div className={cn('rounded-md border overflow-x-auto', className)}>
|
|
333
|
+
<div className={cn('rounded-md border border-border overflow-x-auto', className)}>
|
|
334
334
|
<Table>
|
|
335
335
|
{renderColumnHeaders()}
|
|
336
336
|
|
|
@@ -186,7 +186,7 @@ export function Card<TData = any>({
|
|
|
186
186
|
)}
|
|
187
187
|
|
|
188
188
|
{secondaryFields && secondaryFields.length > 0 && (
|
|
189
|
-
<div className="border-t pt-3">
|
|
189
|
+
<div className="border-t border-border pt-3">
|
|
190
190
|
<div className="grid grid-cols-2 gap-3">
|
|
191
191
|
{secondaryFields.map((field) => (
|
|
192
192
|
<div
|
|
@@ -157,7 +157,7 @@ export function TableFilters({ config, filters, className, defaultExpanded = fal
|
|
|
157
157
|
const isExpanded = expandedSections.has(section.id)
|
|
158
158
|
|
|
159
159
|
return (
|
|
160
|
-
<div key={section.id} className="border rounded-lg p-3">
|
|
160
|
+
<div key={section.id} className="border border-border rounded-lg p-3">
|
|
161
161
|
<Button
|
|
162
162
|
variant="ghost"
|
|
163
163
|
size="sm"
|
|
@@ -214,7 +214,7 @@ export function TableFilters({ config, filters, className, defaultExpanded = fal
|
|
|
214
214
|
const activeFilterCount = filters.getActiveFilterCount()
|
|
215
215
|
|
|
216
216
|
return (
|
|
217
|
-
<div className={cn('rounded-lg border bg-muted/30', className)}>
|
|
217
|
+
<div className={cn('rounded-lg border border-border bg-muted/30', className)}>
|
|
218
218
|
{/* Collapsible Header */}
|
|
219
219
|
<button
|
|
220
220
|
type="button"
|
|
@@ -37,15 +37,29 @@ export function TablePagination({
|
|
|
37
37
|
const { start, end } = getDisplayRange()
|
|
38
38
|
const pageNumbers = getPageNumbers()
|
|
39
39
|
|
|
40
|
+
// Nothing to count: a range would read "1-0 of 0". Say it in words instead.
|
|
41
|
+
const isEmpty = totalCount <= 0 || end < start || end <= 0
|
|
42
|
+
// `count && <jsx/>` renders the NUMBER 0 when count is 0 — compare explicitly.
|
|
43
|
+
const showFilteredNote =
|
|
44
|
+
totalFilteredCount != null && totalFilteredCount !== totalCount
|
|
45
|
+
const showSelectAll =
|
|
46
|
+
Boolean(showSelectAllPrompt) &&
|
|
47
|
+
Boolean(onSelectAllPages) &&
|
|
48
|
+
(totalFilteredCount ?? 0) > end
|
|
49
|
+
|
|
40
50
|
return (
|
|
41
51
|
<div className={className}>
|
|
42
52
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
43
53
|
{/* Item count display */}
|
|
44
54
|
<div className="text-sm text-muted-foreground">
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
{isEmpty ? (
|
|
56
|
+
'No items'
|
|
57
|
+
) : (
|
|
58
|
+
<>
|
|
59
|
+
Showing <span className="font-medium">{start}-{end}</span> of{' '}
|
|
60
|
+
<span className="font-medium">{totalCount}</span>
|
|
61
|
+
{showFilteredNote && <span> filtered items</span>}
|
|
62
|
+
</>
|
|
49
63
|
)}
|
|
50
64
|
</div>
|
|
51
65
|
|
|
@@ -137,7 +151,7 @@ export function TablePagination({
|
|
|
137
151
|
</div>
|
|
138
152
|
|
|
139
153
|
{/* Select all pages prompt */}
|
|
140
|
-
{
|
|
154
|
+
{showSelectAll && (
|
|
141
155
|
<div className="mt-3 flex items-center justify-center p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
142
156
|
<span className="text-sm text-blue-900 mr-2">
|
|
143
157
|
Select all <span className="font-semibold">{totalFilteredCount}</span> items?
|
|
@@ -97,9 +97,21 @@ export function usePagination({
|
|
|
97
97
|
return pages
|
|
98
98
|
}, [currentPage, totalPages])
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The 1-based span of rows on screen, or `{ start: 0, end: 0 }` when there is
|
|
102
|
+
* no span to describe. `totalCount` is whatever the server reported, so it can
|
|
103
|
+
* legitimately be 0, or drop below the page the user already has open — both
|
|
104
|
+
* cases used to produce a backwards range like `1-0`.
|
|
105
|
+
*/
|
|
100
106
|
const getDisplayRange = useCallback(() => {
|
|
107
|
+
if (totalCount <= 0) return { start: 0, end: 0 }
|
|
108
|
+
|
|
101
109
|
const start = (currentPage - 1) * pageSize + 1
|
|
102
110
|
const end = Math.min(currentPage * pageSize, totalCount)
|
|
111
|
+
|
|
112
|
+
// The page begins past the last row: nothing is shown here.
|
|
113
|
+
if (start > end) return { start: 0, end: 0 }
|
|
114
|
+
|
|
103
115
|
return { start, end }
|
|
104
116
|
}, [currentPage, pageSize, totalCount])
|
|
105
117
|
|
|
@@ -15,19 +15,22 @@ export interface TableTheme {
|
|
|
15
15
|
mobileCardContent: string
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Every rule here names its border colour. A bare `border` / `border-b` takes
|
|
19
|
+
// currentColor in Tailwind v4, which paints table rules in the TEXT colour —
|
|
20
|
+
// near-black rows on a light page, glaring pale rows on a dark tenant.
|
|
18
21
|
export const defaultTheme: TableTheme = {
|
|
19
22
|
name: 'default',
|
|
20
23
|
container: 'w-full',
|
|
21
24
|
header: 'bg-muted/50',
|
|
22
25
|
headerCell: 'px-4 py-3 text-left text-sm font-medium text-muted-foreground',
|
|
23
26
|
body: '',
|
|
24
|
-
row: 'border-b transition-colors',
|
|
27
|
+
row: 'border-b border-border transition-colors',
|
|
25
28
|
rowHover: 'hover:bg-muted/50',
|
|
26
29
|
rowSelected: 'bg-primary/10 border-primary',
|
|
27
30
|
cell: 'px-4 py-3 text-sm',
|
|
28
|
-
pagination: 'flex items-center justify-between px-4 py-3 border-t',
|
|
31
|
+
pagination: 'flex items-center justify-between px-4 py-3 border-t border-border',
|
|
29
32
|
emptyState: 'flex items-center justify-center py-12 text-muted-foreground',
|
|
30
|
-
mobileCard: 'rounded-lg border bg-card shadow-sm',
|
|
33
|
+
mobileCard: 'rounded-lg border border-border bg-card shadow-sm',
|
|
31
34
|
mobileCardHeader: 'p-4 pb-2',
|
|
32
35
|
mobileCardContent: 'p-4 pt-2',
|
|
33
36
|
}
|
|
@@ -37,7 +40,7 @@ export const compactTheme: TableTheme = {
|
|
|
37
40
|
name: 'compact',
|
|
38
41
|
headerCell: 'px-2 py-2 text-left text-xs font-medium text-muted-foreground',
|
|
39
42
|
cell: 'px-2 py-2 text-xs',
|
|
40
|
-
pagination: 'flex items-center justify-between px-2 py-2 border-t',
|
|
43
|
+
pagination: 'flex items-center justify-between px-2 py-2 border-t border-border',
|
|
41
44
|
mobileCardHeader: 'p-3 pb-1',
|
|
42
45
|
mobileCardContent: 'p-3 pt-1',
|
|
43
46
|
}
|
|
@@ -47,7 +50,7 @@ export const spaciousTheme: TableTheme = {
|
|
|
47
50
|
name: 'spacious',
|
|
48
51
|
headerCell: 'px-6 py-4 text-left text-sm font-medium text-muted-foreground',
|
|
49
52
|
cell: 'px-6 py-4 text-sm',
|
|
50
|
-
pagination: 'flex items-center justify-between px-6 py-4 border-t',
|
|
53
|
+
pagination: 'flex items-center justify-between px-6 py-4 border-t border-border',
|
|
51
54
|
mobileCardHeader: 'p-6 pb-3',
|
|
52
55
|
mobileCardContent: 'p-6 pt-3',
|
|
53
56
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The type scale is a contract, so it gets tested like one (startsim-768w.35.2.4).
|
|
3
|
+
*
|
|
4
|
+
* Two things went wrong without it:
|
|
5
|
+
* 1. Every consumer picked sizes by hand. 77% of one app's text landed on
|
|
6
|
+
* `text-sm` or `text-xs` — two ADJACENT steps — so a card title and its
|
|
7
|
+
* body were 1.5px apart and the hierarchy read as flat.
|
|
8
|
+
* 2. DashboardSection hard-coded `text-gray-900` / `text-gray-600`, which is
|
|
9
|
+
* invisible-adjacent on a dark tenant. Apps hand-rolled their own headings
|
|
10
|
+
* rather than use it, which is how you end up with nineteen loose <h2>s.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'fs'
|
|
14
|
+
import * as path from 'path'
|
|
15
|
+
import { render, screen } from '@testing-library/react'
|
|
16
|
+
import {
|
|
17
|
+
TYPE_SCALE,
|
|
18
|
+
TYPE_SCALE_ORDER,
|
|
19
|
+
TYPE_SCALE_DEFAULTS,
|
|
20
|
+
TYPE_SCALE_TOKENS,
|
|
21
|
+
typeClass,
|
|
22
|
+
} from '../type-scale'
|
|
23
|
+
import { THEME_TOKENS } from '../contract'
|
|
24
|
+
import { DashboardSection } from '../../components/dashboard/DashboardSection'
|
|
25
|
+
|
|
26
|
+
const CONTRACT_CSS = fs.readFileSync(
|
|
27
|
+
path.resolve(__dirname, '../../../theme/contract.css'),
|
|
28
|
+
'utf8',
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
describe('the type scale is a real contract', () => {
|
|
32
|
+
it('names every role in packages/ui/theme/contract.css', () => {
|
|
33
|
+
const missing = TYPE_SCALE_TOKENS.filter(
|
|
34
|
+
(token) => !new RegExp(`${token}\\s*:`).test(CONTRACT_CSS),
|
|
35
|
+
)
|
|
36
|
+
expect(missing).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('is listed in THEME_TOKENS so apps can enumerate what they must define', () => {
|
|
40
|
+
expect(THEME_TOKENS.typeScale).toEqual(TYPE_SCALE_TOKENS)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('drives every role off a CSS variable, so one override reshapes the estate', () => {
|
|
44
|
+
for (const role of TYPE_SCALE_ORDER) {
|
|
45
|
+
expect(TYPE_SCALE[role]).toMatch(/text-\[length:var\(--text-[a-z-]+-size,/)
|
|
46
|
+
expect(TYPE_SCALE[role]).toMatch(/leading-\[var\(--text-[a-z-]+-leading,/)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('carries a fallback in every variable, so a component still renders in an app that has not adopted the contract', () => {
|
|
51
|
+
for (const role of TYPE_SCALE_ORDER) {
|
|
52
|
+
// var(--x) with no comma is the failure mode: the utility computes to
|
|
53
|
+
// nothing and the text falls back to the browser default.
|
|
54
|
+
expect(TYPE_SCALE[role]).not.toMatch(/var\(--text-[a-z-]+-(size|leading)\)/)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('gives every role its own size — no two steps land on the same number', () => {
|
|
59
|
+
const sizes = TYPE_SCALE_ORDER.map((role) => TYPE_SCALE_DEFAULTS[role])
|
|
60
|
+
expect(new Set(sizes).size).toBe(sizes.length)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('keeps adjacent steps far enough apart to be seen as different', () => {
|
|
64
|
+
const sizes = TYPE_SCALE_ORDER.map((role) => TYPE_SCALE_DEFAULTS[role])
|
|
65
|
+
for (let i = 1; i < sizes.length; i++) {
|
|
66
|
+
expect(sizes[i]).toBeLessThan(sizes[i - 1])
|
|
67
|
+
// The original defect was a card title 1.5px larger than its own body —
|
|
68
|
+
// a difference nobody can see. 0.125rem (2px) is the floor.
|
|
69
|
+
expect(sizes[i - 1] - sizes[i]).toBeGreaterThanOrEqual(0.125)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('spans a wide enough range that a headline cannot be mistaken for body copy', () => {
|
|
74
|
+
expect(TYPE_SCALE_DEFAULTS.display / TYPE_SCALE_DEFAULTS.body).toBeGreaterThanOrEqual(3)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('sets size and weight only — colour stays a theme decision', () => {
|
|
78
|
+
for (const role of TYPE_SCALE_ORDER) {
|
|
79
|
+
expect(TYPE_SCALE[role]).not.toMatch(/(^|\s)text-(gray|slate|zinc|neutral|stone|foreground)/)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('exposes the roles through a helper for dynamic call sites', () => {
|
|
84
|
+
expect(typeClass('heading')).toBe(TYPE_SCALE.heading)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('DashboardSection is theme-driven, not light-locked', () => {
|
|
89
|
+
const classesOf = (el: Element | null | undefined) =>
|
|
90
|
+
(el?.getAttribute('class') ?? '').split(/\s+/)
|
|
91
|
+
|
|
92
|
+
it('renders the title on the heading step of the scale', () => {
|
|
93
|
+
render(<DashboardSection title="Overview"><div /></DashboardSection>)
|
|
94
|
+
const heading = screen.getByRole('heading', { level: 2, name: 'Overview' })
|
|
95
|
+
|
|
96
|
+
for (const cls of TYPE_SCALE.heading.split(/\s+/)) {
|
|
97
|
+
expect(heading).toHaveClass(cls)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('renders the description on the muted step of the scale', () => {
|
|
102
|
+
render(
|
|
103
|
+
<DashboardSection title="Overview" description="Last 28 days">
|
|
104
|
+
<div />
|
|
105
|
+
</DashboardSection>,
|
|
106
|
+
)
|
|
107
|
+
const description = screen.getByText('Last 28 days')
|
|
108
|
+
|
|
109
|
+
for (const cls of TYPE_SCALE.muted.split(/\s+/)) {
|
|
110
|
+
expect(description).toHaveClass(cls)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('colours text with theme tokens so it reads on a dark tenant', () => {
|
|
115
|
+
const { container } = render(
|
|
116
|
+
<DashboardSection title="Overview" description="Last 28 days">
|
|
117
|
+
<div />
|
|
118
|
+
</DashboardSection>,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
expect(screen.getByRole('heading', { level: 2 })).toHaveClass('text-foreground')
|
|
122
|
+
expect(screen.getByText('Last 28 days')).toHaveClass('text-muted-foreground')
|
|
123
|
+
|
|
124
|
+
// No `gray-*` / `bg-white` anywhere in the subtree — those ignore the theme.
|
|
125
|
+
const offenders = Array.from(container.querySelectorAll('*'))
|
|
126
|
+
.flatMap((el) => classesOf(el))
|
|
127
|
+
.filter((cls) => /^(bg|text|border|divide|ring)-(gray|slate|zinc|neutral|stone)-\d{2,3}$|^bg-white$/.test(cls))
|
|
128
|
+
expect(offenders).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('never draws a bare `border`, which is currentColor in Tailwind v4', () => {
|
|
132
|
+
const { container } = render(
|
|
133
|
+
<DashboardSection title="Overview" action={<button>Export</button>}>
|
|
134
|
+
<div />
|
|
135
|
+
</DashboardSection>,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
for (const el of Array.from(container.querySelectorAll('*'))) {
|
|
139
|
+
const classes = classesOf(el)
|
|
140
|
+
const bare = classes.filter((c) => /^border(-[trblxy])?$/.test(c))
|
|
141
|
+
if (!bare.length) continue
|
|
142
|
+
expect(classes.some((c) => c.startsWith('border-') && !/^border-[trblxy]$/.test(c))).toBe(true)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
package/src/theme/contract.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TYPE_SCALE_TOKENS } from './type-scale'
|
|
2
|
+
|
|
1
3
|
/** All CSS custom property names that apps must define in their theme */
|
|
2
4
|
export const THEME_TOKENS = {
|
|
3
5
|
colors: [
|
|
@@ -33,6 +35,8 @@ export const THEME_TOKENS = {
|
|
|
33
35
|
],
|
|
34
36
|
shadows: ['--shadow-sm', '--shadow-md', '--shadow-lg', '--shadow-xl'],
|
|
35
37
|
typography: ['--font-sans', '--font-mono', '--font-heading'],
|
|
38
|
+
// The type scale — see ./type-scale.ts for the roles these size.
|
|
39
|
+
typeScale: TYPE_SCALE_TOKENS,
|
|
36
40
|
animation: ['--duration-fast', '--duration-normal', '--duration-slow', '--easing-default'],
|
|
37
41
|
borders: ['--border-width'],
|
|
38
42
|
} as const
|
|
@@ -44,5 +48,6 @@ export type ThemeToken =
|
|
|
44
48
|
| ThemeShapeTokens
|
|
45
49
|
| (typeof THEME_TOKENS.shadows)[number]
|
|
46
50
|
| (typeof THEME_TOKENS.typography)[number]
|
|
51
|
+
| (typeof THEME_TOKENS.typeScale)[number]
|
|
47
52
|
| (typeof THEME_TOKENS.animation)[number]
|
|
48
53
|
| (typeof THEME_TOKENS.borders)[number]
|
package/src/theme/index.ts
CHANGED
|
@@ -7,3 +7,13 @@ export { default as tailwindPreset } from './tailwind.preset'
|
|
|
7
7
|
// Design token contract — all CSS variables apps must define
|
|
8
8
|
export { THEME_TOKENS } from './contract'
|
|
9
9
|
export type { ThemeColorTokens, ThemeShapeTokens, ThemeToken } from './contract'
|
|
10
|
+
|
|
11
|
+
// Type scale — the named roles every text size in the estate comes from
|
|
12
|
+
export {
|
|
13
|
+
TYPE_SCALE,
|
|
14
|
+
TYPE_SCALE_ORDER,
|
|
15
|
+
TYPE_SCALE_DEFAULTS,
|
|
16
|
+
TYPE_SCALE_TOKENS,
|
|
17
|
+
typeClass,
|
|
18
|
+
} from './type-scale'
|
|
19
|
+
export type { TypeScaleRole, TypeScaleToken } from './type-scale'
|