zz-meridian 0.24.0 → 0.25.0
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 +1 -1
- package/payload/CHANGELOG.md +10 -0
- package/payload/package.json +1 -1
- package/payload/scripts/audit.ts +3 -1
- package/payload/skills/zz-meridian/references/validation.md +1 -1
- package/payload/src/components/patterns/data-table/README.md +2 -2
- package/payload/src/components/patterns/data-table/index.tsx +15 -12
- package/payload/src/components/ui/table/README.md +1 -1
- package/payload/src/components/ui/table/index.tsx +20 -17
- package/payload/src/views/members.tsx +4 -5
- package/payload/tests/table-cut-cell.test.tsx +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
|
|
4
4
|
|
|
5
|
+
## [0.25.0] · 2026-10-21
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- **Every table column shares the spare width, and `TableHeader`'s `flexible` prop is removed.** A cut cell used to zero its own width (`max-w-0`) and the lead head claimed all the rest (`w-full`), so the record's name sat in empty room while the columns beside it were held at their minimum, cramped at both ends and wrapping. Now every column asks for its content width and a table with room to spare shares it across all of its columns in proportion to those widths. A column whose cells `truncate` asks for its whole content width too and shrinks only when the table lacks room for every column; a column given `width` keeps that width and takes no spare; the `grow` column keeps its floor, 160px, 256px once the table is 672px wide. Drop `flexible` from any `TableHeader`: a `truncate` column now asks for its own width. Meridian is pre-1.0, so a removed prop ships in a minor, as it did in 0.19.0.
|
|
10
|
+
|
|
11
|
+
### New
|
|
12
|
+
|
|
13
|
+
- **`Cut`**, exported from `@/components/ui/table`: the wrapper a cut cell renders, one grid track of `minmax(0, max-content)` around a `truncate` span. `TableCell truncate` and `DataTable`'s `truncate` columns use it; use it in a hand-built cell that should be cut the same way. The audit's title check reaches through it.
|
|
14
|
+
|
|
5
15
|
## [0.24.0] · 2026-10-20
|
|
6
16
|
|
|
7
17
|
### New
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
|
package/payload/scripts/audit.ts
CHANGED
|
@@ -148,7 +148,9 @@ const MEASURE = `(() => {
|
|
|
148
148
|
// cell titled "Orders" over a cut account id passed as keeping it.
|
|
149
149
|
const whole = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
150
150
|
const keeps = (el, title) => { const t = whole(title), own = whole(el.textContent); return t.length > 0 && own.length > 0 && t.includes(own); };
|
|
151
|
-
|
|
151
|
+
// The walk reaches through a table cell's wrapper: a name span in a flex row in a link in the wrapper's cut span in the
|
|
152
|
+
// wrapper is six levels, so the cell's title is still found.
|
|
153
|
+
const titled = (el) => { for (let n = el, i = 0; n && i < 6; n = deepParent(n), i++) if (keeps(el, n.getAttribute('title'))) return true; return [...el.querySelectorAll('[title]')].some((n) => keeps(el, n.getAttribute('title'))); };
|
|
152
154
|
const inked = [];
|
|
153
155
|
const ringSig = (c) => [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow, c.borderTopStyle, c.borderTopWidth, c.borderTopColor, c.borderBottomColor, c.borderLeftColor, c.borderRightColor].join('|');
|
|
154
156
|
const texts = [];
|
|
@@ -138,7 +138,7 @@ and supply-chain check before a script runs; that is pnpm, not an install, and n
|
|
|
138
138
|
| `unmeasured: x-card (no open shadow root)` | A custom element shows a box but exposes no open root and no content in the page, so nothing inside it was measured | Attach its root open (`attachShadow({ mode: 'open' })`), or render its content as light DOM in a slot; the audit fails until it can look |
|
|
139
139
|
| `unmeasured: x-card (not defined)` | A custom element was still not registered after the settle wait | Register it (`customElements.define`) before the page settles, or remove the tag from the page |
|
|
140
140
|
| `"Export" does nothing when pressed` | A control with no action, or one that only works on hover | Wire it, or remove it; an info button uses `Tooltip toggle` so a tap opens it |
|
|
141
|
-
| `table … > its frame` | A table wider than its card, its last columns clipped | `hideBelow` one more column, or `truncate`
|
|
141
|
+
| `table … > its frame` | A table wider than its card, its last columns clipped | `hideBelow` one more column, or `truncate` a long column. Every column asks for its content width, the spare is shared in proportion, and a cut column shrinks only when the table lacks room; a fixed `width` keeps its width, and the `grow` floor is 160px, 256px from a 672px table |
|
|
142
142
|
| `exception:` / `console.error:` | A runtime error or a hydration mismatch | Hydration: no `Date.now()`, `Math.random()` or locale-dependent formatting in render; use the demo clock and `src/lib/format*` |
|
|
143
143
|
|
|
144
144
|
## Keyboard walk failures
|
|
@@ -27,7 +27,7 @@ Status: beta
|
|
|
27
27
|
|
|
28
28
|
## Sizes
|
|
29
29
|
|
|
30
|
-
Row height and card padding come from the density: comfortable 52px and 24px, compact 38px and 16px. The table never sets its own width
|
|
30
|
+
Row height and card padding come from the density: comfortable 52px and 24px, compact 38px and 16px. The table never sets its own width. Every column asks for its content width, and the spare is shared across all columns in proportion to those widths, with the `grow` column's floor (the record's name: 160px, 256px once the table is 672px wide) counted as its preferred width. A column whose cells `truncate` asks for its whole content width too and shrinks only when the table lacks room for every column; every column that must show its whole value keeps its content width. A column whose content is short and fixed (an ID, a key, a date) can take `width` (`w-28`), which it keeps, taking no spare, so a two-column table does not hand the short column most of the row.
|
|
31
31
|
|
|
32
32
|
## States
|
|
33
33
|
|
|
@@ -85,7 +85,7 @@ Rows arrive whole for client-side sorting and paging (a few hundred at most), or
|
|
|
85
85
|
|
|
86
86
|
## Do and do not
|
|
87
87
|
|
|
88
|
-
- Do give the record's name `grow
|
|
88
|
+
- Do give the record's name `grow`: it sets the floor and the phone card's title, and claims no spare. A column may be cut (`truncate`) if its values are long, since it shows them whole while the table has room and shrinks only when it has not.
|
|
89
89
|
- Do drop columns with `hideBelow` rather than letting the table scroll sideways.
|
|
90
90
|
- Do not put more than one action per row; a row with several actions opens a record that has them.
|
|
91
91
|
- Do not show pagination for a list that fits on one page.
|
|
@@ -12,7 +12,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|
|
12
12
|
import { EmptyState } from '@/components/ui/empty-state';
|
|
13
13
|
import { Pagination } from '@/components/ui/pagination';
|
|
14
14
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
15
|
-
import { Table, TableBody, TableHead, TableHeader, type Breakpoint, type SortDirection } from '@/components/ui/table';
|
|
15
|
+
import { Cut, Table, TableBody, TableHead, TableHeader, type Breakpoint, type SortDirection } from '@/components/ui/table';
|
|
16
16
|
import { useQueryState } from './use-query-state';
|
|
17
17
|
|
|
18
18
|
export { useQueryState } from './use-query-state';
|
|
@@ -36,9 +36,9 @@ export type Column<R> = {
|
|
|
36
36
|
title?: (row: R) => string;
|
|
37
37
|
/** Drop the column below this width (the least important columns go first). */
|
|
38
38
|
hideBelow?: Breakpoint;
|
|
39
|
-
/** The one column
|
|
39
|
+
/** The record's name: the one column with a floor, so the last to be cut. It claims no spare width. */
|
|
40
40
|
grow?: boolean;
|
|
41
|
-
/** A width class for a column whose content is short and fixed (an ID, a key, a date), so the spare
|
|
41
|
+
/** A width class for a column whose content is short and fixed (an ID, a key, a date), so the spare is shared by the others: `w-28`. */
|
|
42
42
|
width?: string;
|
|
43
43
|
/**
|
|
44
44
|
* Its place in the card a row becomes on phones: the `title` (one column, the record's name), a `status` at the end
|
|
@@ -96,7 +96,7 @@ const PHONE_ROWS = [
|
|
|
96
96
|
].join(' ');
|
|
97
97
|
|
|
98
98
|
/** The resets a cell needs once the row is a card: not a row height, not a padding box, free to shrink and to clip. */
|
|
99
|
-
const PHONE_CELLS = '@max-[640px]:[&_td:not([data-mobile=hidden])]:!h-auto @max-[640px]:[&_td:not([data-mobile=hidden])]:!
|
|
99
|
+
const PHONE_CELLS = '@max-[640px]:[&_td:not([data-mobile=hidden])]:!h-auto @max-[640px]:[&_td:not([data-mobile=hidden])]:!p-0 @max-[640px]:[&_td:not([data-mobile=hidden])]:min-w-0 @max-[640px]:[&_td:not([data-mobile=hidden]):not([data-mobile=fact])]:!block @max-[640px]:[&_[data-sep]]:inline';
|
|
100
100
|
|
|
101
101
|
/** Where a role sits in the card, and how it reads there.
|
|
102
102
|
*
|
|
@@ -124,7 +124,7 @@ const HIDE: Record<Breakpoint, string> = { sm: '@max-[512px]:hidden', md: '@max-
|
|
|
124
124
|
|
|
125
125
|
/** A cell's few own classes: what its column adds to the table's rules. */
|
|
126
126
|
const cellClass = (c: { align?: 'left' | 'right' | 'center'; numeric?: boolean; muted?: boolean; truncate?: boolean; hideBelow?: Breakpoint }) =>
|
|
127
|
-
cn(c.numeric && 't-num', c.align === 'center' && '!text-center', c.muted && 'text-ink-2', c.
|
|
127
|
+
cn(c.numeric && 't-num', c.align === 'center' && '!text-center', c.muted && 'text-ink-2', c.hideBelow && HIDE[c.hideBelow]);
|
|
128
128
|
|
|
129
129
|
/** The table's sort and page, kept in the URL: `?sort=latency&dir=desc&page=2`. Wrap the page in <Suspense>. */
|
|
130
130
|
export function useTableQuery(defaults: Partial<TableState> = {}) {
|
|
@@ -302,7 +302,7 @@ export function DataTable<R>({
|
|
|
302
302
|
</TableHeader>
|
|
303
303
|
) : null}
|
|
304
304
|
{columns.map((c) => (
|
|
305
|
-
<TableHeader key={c.key} align={c.align ?? (c.numeric ? 'right' : 'left')} hideBelow={c.hideBelow} grow={c.grow}
|
|
305
|
+
<TableHeader key={c.key} align={c.align ?? (c.numeric ? 'right' : 'left')} hideBelow={c.hideBelow} grow={c.grow} className={c.width} sort={c.sortValue ? dirOf(c) : undefined} onSort={c.sortValue ? () => toggleSort(c) : undefined}>
|
|
306
306
|
{c.header}
|
|
307
307
|
</TableHeader>
|
|
308
308
|
))}
|
|
@@ -350,12 +350,15 @@ export function DataTable<R>({
|
|
|
350
350
|
{role === 'fact' && facts.indexOf(c) > 0 ? <span aria-hidden data-sep>·</span> : null}
|
|
351
351
|
{/* A phone may want other words for the same cell — "Used 1 min ago", not "1 min ago" — so
|
|
352
352
|
both are rendered and one is hidden: a few words each, never a second copy of the row. */}
|
|
353
|
-
{
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
353
|
+
{(() => {
|
|
354
|
+
const body = c.mobileCell ? (
|
|
355
|
+
<>
|
|
356
|
+
<span className="@max-[640px]:hidden">{content}</span>
|
|
357
|
+
<span className="hidden @max-[640px]:contents">{c.mobileCell(r)}</span>
|
|
358
|
+
</>
|
|
359
|
+
) : content;
|
|
360
|
+
return c.truncate ? <Cut>{body}</Cut> : body;
|
|
361
|
+
})()}
|
|
359
362
|
</td>
|
|
360
363
|
);
|
|
361
364
|
})}
|
|
@@ -24,7 +24,7 @@ The names follow the HTML elements: `TableHead` is the `thead`, `TableHeader` is
|
|
|
24
24
|
|
|
25
25
|
## Sizes
|
|
26
26
|
|
|
27
|
-
Column widths follow content.
|
|
27
|
+
Column widths follow content. Every column asks for its content width, and a table with spare width shares it across all of its columns in proportion to those widths, so no column holds the spare alone; `grow` on the head marks the record's name, whose floor counts as its preferred width when the floor is wider than the text. A column whose cells can be cut (`truncate`) asks for its whole content width too, and shrinks only when the table lacks room for every column; then the cut columns give way, which is what a table with three long columns (a verification, its release, the reason it failed) needs. Every column that must show its whole value keeps its content width. A column given `width` keeps that width and takes no spare. A cut cell carries the full text in its `title`, or opens the record. Other text columns stay on one line. The `grow` column is at least 160px, 256px once the table is 672px wide. `hideBelow` drops a column when the table itself is narrower than `sm` 512, `md` 672, `lg` 896 or `xl` 1152px, on its head and its cells alike, and marks each `data-hide-below`, so the column drawn last at that width, not the dropped one after it, takes the card's padding on its right.
|
|
28
28
|
|
|
29
29
|
## States
|
|
30
30
|
|
|
@@ -74,7 +74,6 @@ export function TableHeader({
|
|
|
74
74
|
sort,
|
|
75
75
|
onSort,
|
|
76
76
|
grow,
|
|
77
|
-
flexible,
|
|
78
77
|
children,
|
|
79
78
|
...rest
|
|
80
79
|
}: ThHTMLAttributes<HTMLTableCellElement> & {
|
|
@@ -82,10 +81,8 @@ export function TableHeader({
|
|
|
82
81
|
hideBelow?: Breakpoint;
|
|
83
82
|
sort?: SortDirection;
|
|
84
83
|
onSort?: () => void;
|
|
85
|
-
/** The one column
|
|
84
|
+
/** The record's name: the one column with a floor, so it is the last to be cut. It claims no spare width. */
|
|
86
85
|
grow?: boolean;
|
|
87
|
-
/** The column's text can be cut rather than wrapped, so it can use width the other columns cannot. */
|
|
88
|
-
flexible?: boolean;
|
|
89
86
|
}) {
|
|
90
87
|
const sortable = onSort !== undefined;
|
|
91
88
|
const Icon = sort === 'asc' ? ArrowUp : sort === 'desc' ? ArrowDown : ChevronsUpDown;
|
|
@@ -99,17 +96,11 @@ export function TableHeader({
|
|
|
99
96
|
'h-9 border-y border-line bg-surface-sunk px-4 text-xs font-medium whitespace-nowrap text-ink-3 first:border-l-0',
|
|
100
97
|
ALIGN[align],
|
|
101
98
|
hideBelow && HIDE[hideBelow],
|
|
102
|
-
/*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
the browser nothing to spread; two or more such columns share it between them, which is what the tables with
|
|
108
|
-
three long columns (Tests: the verification, the release, the reason) need. */
|
|
109
|
-
(grow || flexible) && 'w-full',
|
|
110
|
-
// The floor belongs to the lead column alone. A second column that can be cut asks for the slack but must be
|
|
111
|
-
// able to give it up: with the same floor on two columns the Releases table held 837px inside a 713px frame
|
|
112
|
-
// at 1024 and 768, and no column could shrink because both had a minimum.
|
|
99
|
+
/* No head claims the spare. A cut column asks for its content's width through its cells (`Cut`), so auto layout
|
|
100
|
+
shares the spare across every column in proportion to that ask; a head with `w-full` took all of it for the
|
|
101
|
+
lead and left the rest at their minimum, the uneven spacing this replaced. */
|
|
102
|
+
// The floor belongs to the lead column alone (c8eccdc): a second column with a minimum cannot shrink, and the
|
|
103
|
+
// Releases table held 837px inside a 713px frame at 1024 and 768 when two columns had one.
|
|
113
104
|
grow && 'min-w-40 @min-[672px]:min-w-64',
|
|
114
105
|
className,
|
|
115
106
|
)}
|
|
@@ -137,6 +128,19 @@ export function TableHeader({
|
|
|
137
128
|
);
|
|
138
129
|
}
|
|
139
130
|
|
|
131
|
+
/**
|
|
132
|
+
* A cut value: it asks for its whole content's width and shrinks only when the table has no room. One grid track of
|
|
133
|
+
* `minmax(0, max-content)` gives the cell that ask, where `max-w-0` on the cell zeroed it and sent every spare pixel to
|
|
134
|
+
* the lead column.
|
|
135
|
+
*/
|
|
136
|
+
export function Cut({ children }: { children?: ReactNode }) {
|
|
137
|
+
return (
|
|
138
|
+
<span className="grid min-w-0 grid-cols-[minmax(0,max-content)]">
|
|
139
|
+
<span className="truncate">{children}</span>
|
|
140
|
+
</span>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
140
144
|
export function TableCell({
|
|
141
145
|
className,
|
|
142
146
|
align,
|
|
@@ -160,13 +164,12 @@ export function TableCell({
|
|
|
160
164
|
ALIGN[align ?? (numeric ? 'right' : 'left')],
|
|
161
165
|
numeric && 't-num whitespace-nowrap',
|
|
162
166
|
muted ? 'text-ink-2' : 'text-ink',
|
|
163
|
-
truncate && 'max-w-0 truncate',
|
|
164
167
|
hideBelow && HIDE[hideBelow],
|
|
165
168
|
className,
|
|
166
169
|
)}
|
|
167
170
|
{...rest}
|
|
168
171
|
>
|
|
169
|
-
{children}
|
|
172
|
+
{truncate ? <Cut>{children}</Cut> : children}
|
|
170
173
|
</td>
|
|
171
174
|
);
|
|
172
175
|
}
|
|
@@ -196,11 +196,10 @@ export function MembersView({ rows, now, revisions, actions, access, roles, work
|
|
|
196
196
|
</span>
|
|
197
197
|
),
|
|
198
198
|
},
|
|
199
|
-
// The name is the row's identifier and
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// every address over two lines; held on one line, the column is as wide as the longest and nothing is cut.
|
|
199
|
+
// The name is the row's identifier and the table's floor; the email is not a second one, and it is a fixed-width
|
|
200
|
+
// thing (a domain and a local part), so it is drawn whole on one line at the width its longest one needs. Every
|
|
201
|
+
// column asks for its content width and the table shares the spare in proportion to those widths, so neither
|
|
202
|
+
// column claims the room. Held on one line, the email column is as wide as the longest address and nothing is cut.
|
|
204
203
|
{ key: 'email', header: 'Email', muted: true, hideBelow: 'lg', sortValue: (m) => m.email, cell: (m) => <span className="whitespace-nowrap">{m.email}</span> },
|
|
205
204
|
// The role as a person reads it, add-ons and all, as a column and as a fact on the phone card (AC-3.2).
|
|
206
205
|
{ key: 'role', header: 'Role', hideBelow: 'sm', mobile: 'fact', sortValue: (m) => m.role, cell: (m) => roleLabel(m), mobileCell: (m) => roleLabel(m) },
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('next/navigation', () => ({ useRouter: () => ({ push() {}, refresh() {}, replace() {} }), usePathname: () => '/' }));
|
|
5
|
+
import { DataTable, type Column } from '@/components/patterns/data-table';
|
|
6
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
7
|
+
|
|
8
|
+
const WRAP = ['grid', 'min-w-0', 'grid-cols-[minmax(0,max-content)]'];
|
|
9
|
+
const classes = (el: Element) => el.getAttribute('class')?.split(/\s+/) ?? [];
|
|
10
|
+
const wrapOf = (td: Element) => [...td.children].find((c) => WRAP.every((k) => classes(c).includes(k)));
|
|
11
|
+
|
|
12
|
+
/** A cut cell asks for its whole content and shrinks only when the table has no room; it never zeroes its width. */
|
|
13
|
+
function expectWrapped(td: Element, text: string) {
|
|
14
|
+
expect(classes(td)).not.toContain('max-w-0');
|
|
15
|
+
expect(classes(td)).not.toContain('truncate');
|
|
16
|
+
const wrap = wrapOf(td);
|
|
17
|
+
expect(wrap, 'the one-track wrapper').toBeTruthy();
|
|
18
|
+
expect(wrap!.children).toHaveLength(1);
|
|
19
|
+
expect(classes(wrap!.children[0])).toContain('truncate');
|
|
20
|
+
expect(wrap!.textContent).toContain(text);
|
|
21
|
+
return wrap!;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('a cut cell keeps its content width (spec AC-1.4)', () => {
|
|
25
|
+
it('TableCell wraps a truncating value and keeps its title', () => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<Table>
|
|
28
|
+
<TableHead><TableRow><TableHeader grow>Request</TableHeader><TableHeader>Status</TableHeader></TableRow></TableHead>
|
|
29
|
+
<TableBody><TableRow><TableCell truncate>/v1/messages/with/a/long/tail</TableCell><TableCell>200</TableCell></TableRow></TableBody>
|
|
30
|
+
</Table>,
|
|
31
|
+
);
|
|
32
|
+
const td = container.querySelector('td')!;
|
|
33
|
+
expectWrapped(td, '/v1/messages/with/a/long/tail');
|
|
34
|
+
expect(td.getAttribute('title')).toBe('/v1/messages/with/a/long/tail');
|
|
35
|
+
const heads = [...container.querySelectorAll('th')];
|
|
36
|
+
expect(heads.some((th) => classes(th).includes('w-full'))).toBe(false);
|
|
37
|
+
expect(classes(heads[0])).toEqual(expect.arrayContaining(['min-w-40', '@min-[672px]:min-w-64']));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
type Row = { id: string; name: string; plan: string; region: string };
|
|
42
|
+
const rows: Row[] = [{ id: 'cus_8f2a', name: 'Northwind Labs International Holdings', plan: 'Enterprise', region: 'eu-west-1' }];
|
|
43
|
+
|
|
44
|
+
describe('DataTable shares the spare (spec AC-1.4, AC-1.6)', () => {
|
|
45
|
+
it('wraps every cut column, claims no spare on any head, and keeps fixed widths and the floor', () => {
|
|
46
|
+
const columns: Column<Row>[] = [
|
|
47
|
+
{ key: 'name', header: 'Customer', grow: true, truncate: true, mobile: 'title', title: (r) => r.name, cell: (r) => <span className="flex min-w-0 items-center gap-3"><span className="truncate font-medium">{r.name}</span></span> },
|
|
48
|
+
{ key: 'id', header: 'ID', width: 'w-28', cell: (r) => r.id },
|
|
49
|
+
{ key: 'plan', header: 'Plan', truncate: true, mobile: 'fact', cell: (r) => r.plan },
|
|
50
|
+
];
|
|
51
|
+
const { container } = render(<DataTable caption="Customers" noun="customers" rows={rows} columns={columns} rowKey={(r) => r.id} selectable selected={new Set()} onSelectedChange={() => {}} />);
|
|
52
|
+
const cells = [...container.querySelectorAll('tbody tr:first-child td')];
|
|
53
|
+
const byText = (t: string) => cells.find((td) => td.textContent?.includes(t))!;
|
|
54
|
+
expectWrapped(byText('Northwind'), 'Northwind Labs International Holdings');
|
|
55
|
+
expect(byText('Northwind').getAttribute('title')).toBe('Northwind Labs International Holdings');
|
|
56
|
+
expectWrapped(byText('Enterprise'), 'Enterprise');
|
|
57
|
+
expect(classes(byText('cus_8f2a'))).not.toContain('max-w-0');
|
|
58
|
+
const heads = [...container.querySelectorAll('thead th')];
|
|
59
|
+
expect(heads.some((th) => classes(th).includes('w-full'))).toBe(false);
|
|
60
|
+
expect(classes(heads[0])).toContain('w-10');
|
|
61
|
+
const customer = heads.find((th) => th.textContent?.includes('Customer'))!;
|
|
62
|
+
expect(classes(customer)).toEqual(expect.arrayContaining(['min-w-40', '@min-[672px]:min-w-64']));
|
|
63
|
+
expect(classes(heads.find((th) => th.textContent?.includes('ID'))!)).toContain('w-28');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("keeps a fact cell's separator outside the wrapper, and the row link and the phone span inside it", () => {
|
|
67
|
+
const columns: Column<Row>[] = [
|
|
68
|
+
{ key: 'name', header: 'Customer', grow: true, truncate: true, mobile: 'title', title: (r) => r.name, cell: (r) => r.name },
|
|
69
|
+
{ key: 'plan', header: 'Plan', truncate: true, mobile: 'fact', cell: (r) => r.plan, mobileCell: (r) => `${r.plan} plan` },
|
|
70
|
+
{ key: 'region', header: 'Region', truncate: true, mobile: 'fact', cell: (r) => r.region },
|
|
71
|
+
];
|
|
72
|
+
const { container } = render(<DataTable caption="Customers" noun="customers" rows={rows} columns={columns} rowKey={(r) => r.id} rowHref={(r) => `/customers/${r.id}`} />);
|
|
73
|
+
const [title, plan, region] = [...container.querySelectorAll('tbody tr:first-child td')];
|
|
74
|
+
const titleWrap = expectWrapped(title, 'Northwind Labs International Holdings');
|
|
75
|
+
expect(titleWrap.querySelector('a[href="/customers/cus_8f2a"]')).toBeTruthy();
|
|
76
|
+
expectWrapped(plan, 'Enterprise plan');
|
|
77
|
+
const sep = region.querySelector('[data-sep]')!;
|
|
78
|
+
expect(sep).toBeTruthy();
|
|
79
|
+
expect(sep.parentElement).toBe(region);
|
|
80
|
+
expect(sep.nextElementSibling).toBe(wrapOf(region));
|
|
81
|
+
expectWrapped(region, 'eu-west-1');
|
|
82
|
+
});
|
|
83
|
+
});
|