@xenide-io/the-old-ui-theme 0.10.0 → 0.11.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.
Files changed (42) hide show
  1. package/dist/{Chip-DKT-82Lf.d.mts → Chip-Dw2g2t8D.d.mts} +14 -2
  2. package/dist/{Chip-DKT-82Lf.d.ts → Chip-Dw2g2t8D.d.ts} +14 -2
  3. package/dist/{chunk-LHE4ROON.mjs → chunk-RIPGN6FG.mjs} +412 -61
  4. package/dist/index.d.mts +3 -2
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +355 -23
  7. package/dist/index.mjs +11 -3
  8. package/dist/suite-analytics--Mf1RZbw.d.mts +29 -0
  9. package/dist/suite-analytics--Mf1RZbw.d.ts +29 -0
  10. package/dist/suite.d.mts +14 -4
  11. package/dist/suite.d.ts +14 -4
  12. package/dist/suite.js +920 -513
  13. package/dist/suite.mjs +898 -523
  14. package/dist/ui.d.mts +2 -2
  15. package/dist/ui.d.ts +2 -2
  16. package/dist/ui.js +401 -66
  17. package/dist/ui.mjs +3 -1
  18. package/package.json +1 -1
  19. package/src/app/globals.css +57 -1
  20. package/src/components/ui/BrandSpinner.tsx +256 -0
  21. package/src/components/ui/CodeBlock.tsx +11 -1
  22. package/src/components/ui/Marketing.tsx +17 -1
  23. package/src/components/ui/SettingsLayout.tsx +4 -0
  24. package/src/components/ui/Table.tsx +32 -9
  25. package/src/components/ui/brand-spinner.test.tsx +62 -0
  26. package/src/components/ui/index.ts +7 -0
  27. package/src/components/ui/table.test.tsx +31 -4
  28. package/src/styles/suite-skin.css +78 -46
  29. package/src/suite/components/ai-draft-editor.tsx +184 -0
  30. package/src/suite/components/ai-panel-workflows.test.tsx +179 -0
  31. package/src/suite/components/ai-panel.tsx +184 -14
  32. package/src/suite/components/app-switcher.tsx +13 -0
  33. package/src/suite/components/command-palette-host.tsx +20 -2
  34. package/src/suite/components/suite-notification-bell.tsx +23 -2
  35. package/src/suite/components/suite-notification-settings.tsx +11 -0
  36. package/src/suite/components/suite-settings-layout.tsx +1 -1
  37. package/src/suite/components/suite-settings-mobile-nav.tsx +5 -1
  38. package/src/suite/components/suite-sidebar.tsx +2 -2
  39. package/src/suite/components/theme-provider.tsx +2 -0
  40. package/src/suite/components/today-ui.tsx +33 -28
  41. package/src/suite/index.ts +8 -0
  42. package/src/suite/lib/suite-notifications.tsx +5 -0
package/dist/ui.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  AvatarGroup,
11
11
  Badge,
12
12
  BarChart,
13
+ BrandSpinner,
13
14
  Button,
14
15
  ButtonChrome,
15
16
  Calendar,
@@ -85,7 +86,7 @@ import {
85
86
  Tooltip,
86
87
  TooltipProvider,
87
88
  useChartTokens
88
- } from "./chunk-LHE4ROON.mjs";
89
+ } from "./chunk-RIPGN6FG.mjs";
89
90
  import "./chunk-FWCSY2DS.mjs";
90
91
  export {
91
92
  Accordion,
@@ -98,6 +99,7 @@ export {
98
99
  AvatarGroup,
99
100
  Badge,
100
101
  BarChart,
102
+ BrandSpinner,
101
103
  Button,
102
104
  ButtonChrome,
103
105
  Calendar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenide-io/the-old-ui-theme",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Props-driven React components and theme tokens inspired by classic interface design — optimized for Next.js apps and internal tools.",
5
5
  "author": "Xenide",
6
6
  "license": "MIT",
@@ -112,6 +112,20 @@ body {
112
112
  }
113
113
 
114
114
  @layer components {
115
+ .suite-sidebar-link:focus-visible {
116
+ outline: none;
117
+ box-shadow: var(--ph-focus-halo);
118
+ }
119
+
120
+ .ph-brand-spinner__column {
121
+ animation: ph-spinner-columns 0.85s ease-in-out infinite;
122
+ }
123
+
124
+ @keyframes ph-spinner-columns {
125
+ 0%, 100% { opacity: 0.35; transform: translateY(2px); }
126
+ 50% { opacity: 1; transform: translateY(-2px); }
127
+ }
128
+
115
129
  .ph-page-title {
116
130
  font-size: var(--ph-type-page);
117
131
  line-height: var(--ph-leading-title);
@@ -983,6 +997,47 @@ body {
983
997
  }
984
998
 
985
999
  /* LemonTable.scss — sticky-header border Simulation + row rhythm */
1000
+ .ph-table-report-wrap {
1001
+ overflow-x: auto;
1002
+ }
1003
+
1004
+ .ph-table-report-wrap:focus-visible {
1005
+ outline: 3px solid color-mix(in oklab, var(--ph-focus-ring) 30%, transparent);
1006
+ outline-offset: 2px;
1007
+ }
1008
+
1009
+ /* Preserve the existing report table geometry and sentence-case headings. */
1010
+ .ph-table-report {
1011
+ @apply w-full text-left text-sm;
1012
+ }
1013
+
1014
+ .ph-table-report thead {
1015
+ color: var(--ph-text-secondary);
1016
+ }
1017
+
1018
+ .ph-table-report tr {
1019
+ border-bottom: 1px solid var(--ph-border);
1020
+ }
1021
+
1022
+ .ph-table-report th:first-child,
1023
+ .ph-table-report td:first-child {
1024
+ padding-block: 0.75rem;
1025
+ }
1026
+
1027
+ .ph-table-report th:not(:last-child),
1028
+ .ph-table-report td:not(:last-child) {
1029
+ padding-right: 1rem;
1030
+ }
1031
+
1032
+ .ph-table-compact .ph-table-report {
1033
+ font-size: var(--ph-type-meta);
1034
+ }
1035
+
1036
+ .ph-table-compact .ph-table-report th:first-child,
1037
+ .ph-table-compact .ph-table-report td:first-child {
1038
+ padding-block: 0.375rem;
1039
+ }
1040
+
986
1041
  .ph-table-wrap {
987
1042
  @apply overflow-x-auto bg-ph-surface;
988
1043
  border-radius: var(--ph-radius-surface);
@@ -1063,7 +1118,8 @@ body {
1063
1118
  font-size: var(--ph-type-meta);
1064
1119
  }
1065
1120
 
1066
- .ph-table td.ph-table-empty {
1121
+ .ph-table td.ph-table-empty,
1122
+ .ph-table-report td.ph-table-empty {
1067
1123
  @apply py-8 text-center text-sm text-ph-mutedtext;
1068
1124
  }
1069
1125
 
@@ -0,0 +1,256 @@
1
+ "use client";
2
+
3
+ import { useId, useSyncExternalStore, type HTMLAttributes } from "react";
4
+ import { cn } from "@/lib/cn";
5
+
6
+ export type BrandSpinnerVariant =
7
+ "ring" | "timer" | "columns" | "orbit" | "ink";
8
+ export type BrandSpinnerSize = "sm" | "md" | "lg";
9
+
10
+ export interface BrandSpinnerProps extends HTMLAttributes<HTMLDivElement> {
11
+ variant?: BrandSpinnerVariant;
12
+ size?: BrandSpinnerSize;
13
+ label?: string;
14
+ }
15
+
16
+ const sizes: Record<BrandSpinnerSize, number> = { sm: 20, md: 40, lg: 56 };
17
+ const reducedMotionQuery = "(prefers-reduced-motion: reduce)";
18
+
19
+ function subscribeMotion(onChange: () => void): () => void {
20
+ const media = window.matchMedia?.(reducedMotionQuery);
21
+ media?.addEventListener("change", onChange);
22
+ return () => media?.removeEventListener("change", onChange);
23
+ }
24
+
25
+ function readReducedMotion(): boolean {
26
+ return window.matchMedia?.(reducedMotionQuery).matches ?? false;
27
+ }
28
+
29
+ export function BrandSpinner({
30
+ variant = "ring",
31
+ size = "md",
32
+ label = "Loading",
33
+ className,
34
+ ...props
35
+ }: BrandSpinnerProps) {
36
+ const gradientId = `ph-spinner-${useId().replace(/:/g, "")}`;
37
+ const reducedMotion = useSyncExternalStore(
38
+ subscribeMotion,
39
+ readReducedMotion,
40
+ () => true,
41
+ );
42
+ const dimension = sizes[size];
43
+
44
+ return (
45
+ <div
46
+ role="status"
47
+ aria-label={label}
48
+ data-test="app-spinner"
49
+ className={cn(
50
+ "inline-flex shrink-0",
51
+ variant !== "orbit" && "text-ph-brand",
52
+ className,
53
+ )}
54
+ {...props}
55
+ >
56
+ <svg
57
+ width={dimension}
58
+ height={dimension}
59
+ viewBox={variant === "ring" ? "0 0 24 24" : "0 0 40 40"}
60
+ aria-hidden
61
+ className={
62
+ variant === "timer" || variant === "ring"
63
+ ? "animate-spin motion-reduce:animate-none"
64
+ : undefined
65
+ }
66
+ >
67
+ {variant === "ring" ? (
68
+ <>
69
+ <circle
70
+ cx="12"
71
+ cy="12"
72
+ r="10"
73
+ fill="none"
74
+ stroke="currentColor"
75
+ strokeWidth="3"
76
+ opacity="0.2"
77
+ />
78
+ <path
79
+ d="M12 2a10 10 0 0 1 10 10"
80
+ fill="none"
81
+ stroke="currentColor"
82
+ strokeWidth="3"
83
+ strokeLinecap="round"
84
+ />
85
+ </>
86
+ ) : variant === "timer" ? (
87
+ <>
88
+ <circle
89
+ cx="20"
90
+ cy="20"
91
+ r="15"
92
+ fill="none"
93
+ stroke="currentColor"
94
+ strokeOpacity="0.18"
95
+ strokeWidth="2.5"
96
+ />
97
+ <circle
98
+ cx="20"
99
+ cy="20"
100
+ r="15"
101
+ fill="none"
102
+ stroke="currentColor"
103
+ strokeWidth="2.5"
104
+ strokeLinecap="round"
105
+ strokeDasharray="24 70"
106
+ />
107
+ <circle cx="20" cy="5" r="2.25" fill="currentColor" />
108
+ </>
109
+ ) : variant === "columns" ? (
110
+ <>
111
+ <rect
112
+ x="9"
113
+ y="16"
114
+ width="6"
115
+ height="14"
116
+ rx="2"
117
+ fill="currentColor"
118
+ fillOpacity="0.35"
119
+ className="ph-brand-spinner__column"
120
+ />
121
+ <rect
122
+ x="17"
123
+ y="11"
124
+ width="6"
125
+ height="19"
126
+ rx="2"
127
+ fill="currentColor"
128
+ className="ph-brand-spinner__column"
129
+ style={{ animationDelay: "0.14s" }}
130
+ />
131
+ <rect
132
+ x="25"
133
+ y="14"
134
+ width="6"
135
+ height="16"
136
+ rx="2"
137
+ fill="currentColor"
138
+ fillOpacity="0.55"
139
+ className="ph-brand-spinner__column"
140
+ style={{ animationDelay: "0.28s" }}
141
+ />
142
+ </>
143
+ ) : variant === "orbit" ? (
144
+ <>
145
+ <defs>
146
+ <linearGradient
147
+ id={gradientId}
148
+ x1="0%"
149
+ y1="0%"
150
+ x2="100%"
151
+ y2="100%"
152
+ >
153
+ <stop offset="0%" stopColor="#1877f2" />
154
+ <stop offset="100%" stopColor="#ebbcba" />
155
+ </linearGradient>
156
+ </defs>
157
+ <circle
158
+ cx="20"
159
+ cy="20"
160
+ r="15"
161
+ fill="none"
162
+ stroke={`url(#${gradientId})`}
163
+ strokeOpacity="0.25"
164
+ strokeWidth="2"
165
+ />
166
+ <g className="origin-[20px_20px] animate-spin motion-reduce:animate-none">
167
+ <circle
168
+ cx="20"
169
+ cy="20"
170
+ r="15"
171
+ fill="none"
172
+ stroke={`url(#${gradientId})`}
173
+ strokeWidth="2.5"
174
+ strokeLinecap="round"
175
+ strokeDasharray="22 72"
176
+ />
177
+ </g>
178
+ <g className="origin-[20px_20px] animate-[spin_1.6s_linear_infinite_reverse] motion-reduce:animate-none">
179
+ <circle
180
+ cx="20"
181
+ cy="20"
182
+ r="9"
183
+ fill="none"
184
+ stroke="#ebbcba"
185
+ strokeWidth="2"
186
+ strokeLinecap="round"
187
+ strokeDasharray="12 45"
188
+ strokeOpacity="0.9"
189
+ />
190
+ </g>
191
+ <circle
192
+ cx="20"
193
+ cy="20"
194
+ r="2.5"
195
+ fill="#1877f2"
196
+ className="animate-pulse motion-reduce:animate-none"
197
+ />
198
+ </>
199
+ ) : (
200
+ <>
201
+ <g>
202
+ {!reducedMotion ? (
203
+ <animateTransform
204
+ attributeName="transform"
205
+ type="rotate"
206
+ from="0 20 20"
207
+ to="360 20 20"
208
+ dur="2.4s"
209
+ repeatCount="indefinite"
210
+ />
211
+ ) : null}
212
+ <circle
213
+ cx="20"
214
+ cy="20"
215
+ r="16"
216
+ fill="none"
217
+ stroke="currentColor"
218
+ strokeOpacity="0.2"
219
+ strokeWidth="1.5"
220
+ strokeDasharray="3 5"
221
+ />
222
+ </g>
223
+ <g>
224
+ {!reducedMotion ? (
225
+ <animateTransform
226
+ attributeName="transform"
227
+ type="rotate"
228
+ from="360 20 20"
229
+ to="0 20 20"
230
+ dur="1.1s"
231
+ repeatCount="indefinite"
232
+ />
233
+ ) : null}
234
+ <circle
235
+ cx="20"
236
+ cy="20"
237
+ r="11"
238
+ fill="none"
239
+ stroke="currentColor"
240
+ strokeWidth="2"
241
+ strokeLinecap="round"
242
+ strokeDasharray="18 52"
243
+ />
244
+ </g>
245
+ <path
246
+ d="M16 14h8v12a1 1 0 0 1-1.4.9L20 25.5l-2.6 1.4A1 1 0 0 1 16 26V14z"
247
+ fill="currentColor"
248
+ fillOpacity="0.85"
249
+ className="animate-pulse motion-reduce:animate-none"
250
+ />
251
+ </>
252
+ )}
253
+ </svg>
254
+ </div>
255
+ );
256
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { cn } from "@/lib/cn";
5
+ import { trackSuiteEvent } from "@/lib/suite-analytics";
5
6
 
6
7
  interface CodeBlockProps {
7
8
  code: string;
@@ -14,7 +15,16 @@ export function CodeBlock({ code, language = "tsx", filename, className }: CodeB
14
15
  const [copied, setCopied] = useState(false);
15
16
 
16
17
  const handleCopy = async () => {
17
- await navigator.clipboard.writeText(code);
18
+ try {
19
+ await navigator.clipboard.writeText(code);
20
+ } catch {
21
+ return;
22
+ }
23
+ trackSuiteEvent("Link Copied", {
24
+ kind: "code",
25
+ copy_kind: "code_block",
26
+ label: filename ?? language,
27
+ });
18
28
  setCopied(true);
19
29
  setTimeout(() => setCopied(false), 2000);
20
30
  };
@@ -24,8 +24,24 @@ export function MarketingHeader({
24
24
  navigation: ReactNode;
25
25
  actions: ReactNode;
26
26
  }) {
27
+ // At the very top nothing sits under the header, so it must stay transparent
28
+ // (the page background shows through from the first pixel). Only once content
29
+ // scrolls beneath it do we frost it.
30
+ const [scrolled, setScrolled] = useState(false);
31
+
32
+ useEffect(() => {
33
+ const onScroll = () => setScrolled(window.scrollY > 4);
34
+ onScroll();
35
+ window.addEventListener("scroll", onScroll, { passive: true });
36
+ return () => window.removeEventListener("scroll", onScroll);
37
+ }, []);
38
+
27
39
  return (
28
- <header {...props} className={cn("marketing-header", className)}>
40
+ <header
41
+ {...props}
42
+ data-scrolled={scrolled ? "true" : "false"}
43
+ className={cn("marketing-header", className)}
44
+ >
29
45
  <div className="marketing-header__inner">
30
46
  <div className="marketing-header__brand">{brand}</div>
31
47
  <nav
@@ -1,6 +1,9 @@
1
+ "use client";
2
+
1
3
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
4
  import { IconTuning } from "@/components/icons";
3
5
  import { cn } from "@/lib/cn";
6
+ import { trackSuiteEvent } from "@/lib/suite-analytics";
4
7
 
5
8
  export interface SettingsNavItem {
6
9
  id: string;
@@ -50,6 +53,7 @@ export function SettingsNav({ groups, className, ...props }: SettingsNavProps) {
50
53
  item.active && "ph-settings-nav__item--active",
51
54
  )}
52
55
  aria-current={item.active ? "page" : undefined}
56
+ onClick={() => trackSuiteEvent("Tab Viewed", { tab: item.id })}
53
57
  >
54
58
  {item.icon ? (
55
59
  <span className="ph-settings-nav__icon">{item.icon}</span>
@@ -3,6 +3,8 @@
3
3
  import type { CSSProperties, Key, ReactNode } from "react";
4
4
  import { cn } from "@/lib/cn";
5
5
 
6
+ export type TableVariant = "default" | "report";
7
+
6
8
  export interface TableColumn<T> {
7
9
  key: string;
8
10
  header: ReactNode;
@@ -19,6 +21,8 @@ export interface TableProps<T> {
19
21
  getRowStyle?: (row: T) => CSSProperties | undefined;
20
22
  getRowKey?: (row: T, index: number) => Key;
21
23
  className?: string;
24
+ tableClassName?: string;
25
+ variant?: TableVariant;
22
26
  caption?: string;
23
27
  emptyState?: ReactNode;
24
28
  }
@@ -32,37 +36,56 @@ export function Table<T>({
32
36
  getRowStyle,
33
37
  getRowKey,
34
38
  className,
39
+ tableClassName,
40
+ variant = "default",
35
41
  caption,
36
42
  emptyState = "No data available.",
37
43
  }: TableProps<T>) {
38
44
  return (
39
45
  <div
40
- className={cn("ph-table-wrap", compact && "ph-table-compact", className)}
46
+ className={cn(
47
+ variant === "report" ? "ph-table-report-wrap" : "ph-table-wrap",
48
+ compact && "ph-table-compact",
49
+ className,
50
+ )}
41
51
  role="region"
42
52
  aria-label={caption ?? "Data table"}
43
53
  tabIndex={0}
44
54
  >
45
- <table className={cn("ph-table", zebra && "ph-table-zebra", ribbon && "ph-table--ribbon")}>
55
+ <table
56
+ className={cn(
57
+ variant === "report" ? "ph-table-report" : "ph-table",
58
+ zebra && "ph-table-zebra",
59
+ ribbon && "ph-table--ribbon",
60
+ tableClassName,
61
+ )}
62
+ >
46
63
  {caption && <caption className="sr-only">{caption}</caption>}
47
64
  <thead>
48
65
  <tr>
49
66
  {columns.map((col) => (
50
- <th key={col.key} className={col.className}>{col.header}</th>
67
+ <th key={col.key} scope="col" className={col.className}>
68
+ {col.header}
69
+ </th>
51
70
  ))}
52
71
  </tr>
53
72
  </thead>
54
73
  <tbody>
55
74
  {data.length === 0 ? (
56
- <tr>
57
- <td colSpan={columns.length} className="ph-table-empty">
58
- {emptyState}
59
- </td>
60
- </tr>
75
+ emptyState === null ? null : (
76
+ <tr>
77
+ <td colSpan={columns.length} className="ph-table-empty">
78
+ {emptyState}
79
+ </td>
80
+ </tr>
81
+ )
61
82
  ) : (
62
83
  data.map((row, i) => (
63
84
  <tr key={getRowKey?.(row, i) ?? i} style={getRowStyle?.(row)}>
64
85
  {columns.map((col) => (
65
- <td key={col.key} className={col.className}>{col.cell(row)}</td>
86
+ <td key={col.key} className={col.className}>
87
+ {col.cell(row)}
88
+ </td>
66
89
  ))}
67
90
  </tr>
68
91
  ))
@@ -0,0 +1,62 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { BrandSpinner } from "./BrandSpinner";
4
+
5
+ afterEach(cleanup);
6
+
7
+ describe("BrandSpinner", () => {
8
+ it("keeps orbit gradient references unique when multiple loaders render", () => {
9
+ const { container } = render(
10
+ <>
11
+ <BrandSpinner variant="orbit" size="sm" label="Loading team" />
12
+ <BrandSpinner variant="orbit" size="lg" label="Loading projects" />
13
+ </>,
14
+ );
15
+ const gradients = container.querySelectorAll("linearGradient");
16
+ expect(gradients[0].id).not.toBe(gradients[1].id);
17
+ expect(
18
+ screen.getByRole("status", { name: "Loading team" }).querySelector("svg"),
19
+ ).toHaveAttribute("width", "20");
20
+ expect(
21
+ screen
22
+ .getByRole("status", { name: "Loading projects" })
23
+ .querySelector("svg"),
24
+ ).toHaveAttribute("width", "56");
25
+ expect(
26
+ container.querySelector(`[stroke="url(#${gradients[1].id})"]`),
27
+ ).not.toBeNull();
28
+ });
29
+
30
+ it("stops SVG animation when reduced-motion preference changes", () => {
31
+ const original = window.matchMedia;
32
+ let notify: (() => void) | undefined;
33
+ const media = {
34
+ matches: false,
35
+ addEventListener: vi.fn((_event: string, listener: () => void) => {
36
+ notify = listener;
37
+ }),
38
+ removeEventListener: vi.fn(),
39
+ };
40
+ Object.defineProperty(window, "matchMedia", {
41
+ configurable: true,
42
+ value: vi.fn(() => media),
43
+ });
44
+
45
+ try {
46
+ const { container, unmount } = render(<BrandSpinner variant="ink" />);
47
+ expect(container.querySelectorAll("animateTransform")).toHaveLength(2);
48
+ act(() => {
49
+ media.matches = true;
50
+ notify?.();
51
+ });
52
+ expect(container.querySelectorAll("animateTransform")).toHaveLength(0);
53
+ unmount();
54
+ expect(media.removeEventListener).toHaveBeenCalled();
55
+ } finally {
56
+ Object.defineProperty(window, "matchMedia", {
57
+ configurable: true,
58
+ value: original,
59
+ });
60
+ }
61
+ });
62
+ });
@@ -134,6 +134,7 @@ export {
134
134
  Table as Table,
135
135
  type TableColumn as TableColumn,
136
136
  type TableProps as TableProps,
137
+ type TableVariant as TableVariant,
137
138
  } from "@/components/ui/Table";
138
139
  export {
139
140
  Stat as Stat,
@@ -260,6 +261,12 @@ export {
260
261
  Spinner as Spinner,
261
262
  type SpinnerProps as SpinnerProps,
262
263
  } from "@/components/ui/Spinner";
264
+ export {
265
+ BrandSpinner,
266
+ type BrandSpinnerProps,
267
+ type BrandSpinnerSize,
268
+ type BrandSpinnerVariant,
269
+ } from "@/components/ui/BrandSpinner";
263
270
  export { Dot as Dot, type DotProps as DotProps } from "@/components/ui/Dot";
264
271
  export {
265
272
  Chip as Chip,
@@ -1,8 +1,10 @@
1
- import { render, screen } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
3
 
4
4
  import { Table } from "./Table";
5
5
 
6
+ afterEach(cleanup);
7
+
6
8
  describe("Table", () => {
7
9
  it("names its scroll region and renders a custom empty state", () => {
8
10
  render(
@@ -14,7 +16,32 @@ describe("Table", () => {
14
16
  />,
15
17
  );
16
18
 
17
- expect(screen.getByRole("region", { name: "People" })).toHaveAttribute("tabindex", "0");
18
- expect(screen.getByRole("cell", { name: "No people yet." })).toHaveAttribute("colspan", "1");
19
+ expect(screen.getByRole("region", { name: "People" })).toHaveAttribute(
20
+ "tabindex",
21
+ "0",
22
+ );
23
+ expect(
24
+ screen.getByRole("cell", { name: "No people yet." }),
25
+ ).toHaveAttribute("colspan", "1");
26
+ });
27
+
28
+ it("lets report pages own their empty state without an extra table row", () => {
29
+ render(
30
+ <Table
31
+ variant="report"
32
+ caption="Run report"
33
+ data={[] as Array<{ name: string }>}
34
+ columns={[{ key: "name", header: "Crew", cell: (row) => row.name }]}
35
+ emptyState={null}
36
+ />,
37
+ );
38
+ expect(
39
+ screen.getByRole("table", { name: "Run report" }),
40
+ ).toBeInTheDocument();
41
+ expect(screen.getByRole("columnheader", { name: "Crew" })).toHaveAttribute(
42
+ "scope",
43
+ "col",
44
+ );
45
+ expect(screen.queryByRole("cell")).not.toBeInTheDocument();
19
46
  });
20
47
  });