zz-meridian 0.24.0 → 0.25.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
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",
@@ -2,6 +2,23 @@
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.1] · 2026-10-21
6
+
7
+ ### Fixed
8
+
9
+ - **A right-aligned or centred cut cell keeps its alignment.** Since 0.25.0 a `truncate` cell's value sits in a grid track as wide as the value, and the cell's `text-align` does not move a track, so a numeric or right-aligned `truncate` cell drew its value at the left. `Cut` takes `align` now and `TableCell` and `DataTable` pass the cell's own; a hand-built cell that uses `Cut` in a right-aligned column passes `align="right"`.
10
+ - **The audit's title check reaches a title column with phone words.** A cut name inside a link and a flex row in a column with `mobileCell` sits seven levels under the cell that carries its title, one more than the walk read, so the audit reported the name as cut without its whole text. The walk reads eight levels.
11
+
12
+ ## [0.25.0] · 2026-10-21
13
+
14
+ ### Breaking
15
+
16
+ - **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. Before 1.0, Meridian marks a breaking change with the minor version, as semver allows for 0.x.
17
+
18
+ ### New
19
+
20
+ - **`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.
21
+
5
22
  ## [0.24.0] · 2026-10-20
6
23
 
7
24
  ### New
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
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.",
@@ -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
- const titled = (el) => { for (let n = el, i = 0; n && i < 4; n = deepParent(n), i++) if (keeps(el, n.getAttribute('title'))) return true; return [...el.querySelectorAll('[title]')].some((n) => keeps(el, n.getAttribute('title'))); };
151
+ // The walk reaches through a table cell's wrapper: a name span in a flex row in a link in a phone column's own span in
152
+ // the cut span in the wrapper in the cell is seven levels, so the cell's title is still found, with one to spare.
153
+ const titled = (el) => { for (let n = el, i = 0; n && i < 8; 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` the lead column. When the lead (`grow`) column holds a long name and another column is wide too, hiding columns may not help: move `grow` to the shorter text column |
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; the columns whose cells can be cut take what remains — `grow` for the record's name, and every column whose cells `truncate` — sharing it where there is more than one, and 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`), so a two-column table does not hand the short column most of the row.
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`. A second column may be cut only if it must be, since two of them then share what the table has left.
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 that takes the remaining width: the record's name. */
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 width goes to the others: `w-28`. */
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])]:!max-w-none @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';
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.truncate && 'max-w-0 truncate', c.hideBelow && HIDE[c.hideBelow]);
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} flexible={c.truncate} className={c.width} sort={c.sortValue ? dirOf(c) : undefined} onSort={c.sortValue ? () => toggleSort(c) : undefined}>
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
- {c.mobileCell ? (
354
- <>
355
- <span className="@max-[640px]:hidden">{content}</span>
356
- <span className="hidden @max-[640px]:contents">{c.mobileCell(r)}</span>
357
- </>
358
- ) : content}
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 align={c.align ?? (c.numeric ? 'right' : 'left')}>{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. A column whose cells can be cut asks for all the width there is — `grow` on the head for the record's name, and `flexible` on any other head whose cells `truncate` — and takes what no other column needs; two or more of them share it, 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 cut cell carries the full text in its `title`, or opens the record. Other text columns stay on one line. A flexible 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.
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
 
@@ -16,6 +16,7 @@ export type Breakpoint = 'sm' | 'md' | 'lg' | 'xl';
16
16
  /* Literal strings: Tailwind finds classes by reading the source. */
17
17
  const HIDE: Record<Breakpoint, string> = { sm: '@max-[512px]:hidden', md: '@max-[672px]:hidden', lg: '@max-[896px]:hidden', xl: '@max-[1152px]:hidden' };
18
18
  const ALIGN = { left: 'text-left', right: 'text-right', center: 'text-center' } as const;
19
+ const JUSTIFY = { left: undefined, right: 'justify-end', center: 'justify-center' } as const;
19
20
 
20
21
  export function Table({ className, caption, children, ...rest }: HTMLAttributes<HTMLTableElement> & { caption?: string }) {
21
22
  return (
@@ -74,7 +75,6 @@ export function TableHeader({
74
75
  sort,
75
76
  onSort,
76
77
  grow,
77
- flexible,
78
78
  children,
79
79
  ...rest
80
80
  }: ThHTMLAttributes<HTMLTableCellElement> & {
@@ -82,10 +82,8 @@ export function TableHeader({
82
82
  hideBelow?: Breakpoint;
83
83
  sort?: SortDirection;
84
84
  onSort?: () => void;
85
- /** The one column that takes the remaining width: the record's name. Its truncating cells shrink last. */
85
+ /** The record's name: the one column with a floor, so it is the last to be cut. It claims no spare width. */
86
86
  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
87
  }) {
90
88
  const sortable = onSort !== undefined;
91
89
  const Icon = sort === 'asc' ? ArrowUp : sort === 'desc' ? ArrowDown : ChevronsUpDown;
@@ -99,17 +97,11 @@ export function TableHeader({
99
97
  '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
98
  ALIGN[align],
101
99
  hideBelow && HIDE[hideBelow],
102
- /* A column that can be cut asks for all the width there is, and the ones that must show their whole value —
103
- a count, a date, a badge — size to that value. It asked for a third before (`w-[30%]`), on the reading that
104
- auto layout spreads the remainder by content: it spreads it across every column, so the lead column was
105
- pinned at a third of the table, cutting titles while "Assigned to" sat in twice the room its longest name
106
- needed, and a second cut column beside it was left "1 of 2 pass…" on every row. Asking for all of it leaves
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.
100
+ /* No head claims the spare. A cut column asks for its content's width through its cells (`Cut`), so auto layout
101
+ shares the spare across every column in proportion to that ask; a head with `w-full` took all of it for the
102
+ lead and left the rest at their minimum, the uneven spacing this replaced. */
103
+ // The floor belongs to the lead column alone (c8eccdc): a second column with a minimum cannot shrink, and the
104
+ // Releases table held 837px inside a 713px frame at 1024 and 768 when two columns had one.
113
105
  grow && 'min-w-40 @min-[672px]:min-w-64',
114
106
  className,
115
107
  )}
@@ -137,6 +129,20 @@ export function TableHeader({
137
129
  );
138
130
  }
139
131
 
132
+ /**
133
+ * A cut value: it asks for its whole content's width and shrinks only when the table has no room. One grid track of
134
+ * `minmax(0, max-content)` gives the cell that ask, where `max-w-0` on the cell zeroed it and sent every spare pixel to
135
+ * the lead column. The track is as wide as the value and the cell's `text-align` does not move it, so it takes the
136
+ * cell's alignment itself.
137
+ */
138
+ export function Cut({ align = 'left', children }: { align?: keyof typeof ALIGN; children?: ReactNode }) {
139
+ return (
140
+ <span className={cn('grid min-w-0 grid-cols-[minmax(0,max-content)]', JUSTIFY[align])}>
141
+ <span className="truncate">{children}</span>
142
+ </span>
143
+ );
144
+ }
145
+
140
146
  export function TableCell({
141
147
  className,
142
148
  align,
@@ -160,13 +166,12 @@ export function TableCell({
160
166
  ALIGN[align ?? (numeric ? 'right' : 'left')],
161
167
  numeric && 't-num whitespace-nowrap',
162
168
  muted ? 'text-ink-2' : 'text-ink',
163
- truncate && 'max-w-0 truncate',
164
169
  hideBelow && HIDE[hideBelow],
165
170
  className,
166
171
  )}
167
172
  {...rest}
168
173
  >
169
- {children}
174
+ {truncate ? <Cut align={align ?? (numeric ? 'right' : 'left')}>{children}</Cut> : children}
170
175
  </td>
171
176
  );
172
177
  }
@@ -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 takes what the table has left; the email is not a second one, and it is a
200
- // fixed-width thing (a domain and a local part), so it is drawn whole on one line at the width its longest one
201
- // needs. Both asked to grow before, and the browser split the slack between them: the name sat in empty room while
202
- // "felix.andersson@zz-meridian.example" was cut to "felix.andersson@zz-meridian.examp…". Free to wrap, it broke
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,29 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
4
+
5
+ const wrapOf = (td: Element) => td.firstElementChild!.getAttribute('class')?.split(/\s+/) ?? [];
6
+
7
+ /** The cut track is as wide as the value, so the cell's text-align cannot move it: the wrapper carries the alignment. */
8
+ describe('a cut cell keeps its alignment', () => {
9
+ it('justifies a right-aligned, numeric or centred value and leaves a left one alone', () => {
10
+ const { container } = render(
11
+ <Table>
12
+ <TableBody>
13
+ <TableRow>
14
+ <TableCell truncate>left</TableCell>
15
+ <TableCell truncate align="right">right</TableCell>
16
+ <TableCell truncate numeric>1,204</TableCell>
17
+ <TableCell truncate align="center">centre</TableCell>
18
+ </TableRow>
19
+ </TableBody>
20
+ </Table>,
21
+ );
22
+ const [left, right, numeric, centre] = [...container.querySelectorAll('td')].map(wrapOf);
23
+ expect(left).not.toContain('justify-end');
24
+ expect(left).not.toContain('justify-center');
25
+ expect(right).toContain('justify-end');
26
+ expect(numeric).toContain('justify-end');
27
+ expect(centre).toContain('justify-center');
28
+ });
29
+ });
@@ -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
+ });