@multiplatform.one/table 7.8.0 → 7.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 (110) hide show
  1. package/README.md +60 -0
  2. package/dist/cjs/components/DataTable/DataTableCardControls.cjs +90 -54
  3. package/dist/cjs/components/DataTable/DataTableCardControls.native.js +93 -56
  4. package/dist/cjs/components/DataTable/DataTableCardControls.native.js.map +1 -1
  5. package/dist/cjs/components/DataTable/DataTableMobileCards.cjs +100 -33
  6. package/dist/cjs/components/DataTable/DataTableMobileCards.native.js +139 -38
  7. package/dist/cjs/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  8. package/dist/cjs/components/DataTable/DataTableRoot.cjs +51 -8
  9. package/dist/cjs/components/DataTable/DataTableRoot.native.js +53 -9
  10. package/dist/cjs/components/DataTable/DataTableRoot.native.js.map +1 -1
  11. package/dist/cjs/components/DataTable/index.native.js +59 -93
  12. package/dist/cjs/components/DataTable/index.native.js.map +1 -1
  13. package/dist/cjs/components/DataTableFilterButton/index.cjs +14 -0
  14. package/dist/cjs/components/DataTableFilterButton/index.native.js +15 -0
  15. package/dist/cjs/components/DataTableFilterButton/index.native.js.map +1 -1
  16. package/dist/cjs/components/DataTableToolbar/index.cjs +178 -101
  17. package/dist/cjs/components/DataTableToolbar/index.native.js +187 -109
  18. package/dist/cjs/components/DataTableToolbar/index.native.js.map +1 -1
  19. package/dist/cjs/hooks/useDataTable.cjs +32 -1
  20. package/dist/cjs/hooks/useDataTable.native.js +37 -1
  21. package/dist/cjs/hooks/useDataTable.native.js.map +1 -1
  22. package/dist/esm/components/DataTable/DataTableCardControls.mjs +89 -54
  23. package/dist/esm/components/DataTable/DataTableCardControls.mjs.map +1 -1
  24. package/dist/esm/components/DataTable/DataTableCardControls.native.js +92 -56
  25. package/dist/esm/components/DataTable/DataTableCardControls.native.js.map +1 -1
  26. package/dist/esm/components/DataTable/DataTableMobileCards.mjs +99 -34
  27. package/dist/esm/components/DataTable/DataTableMobileCards.mjs.map +1 -1
  28. package/dist/esm/components/DataTable/DataTableMobileCards.native.js +138 -39
  29. package/dist/esm/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  30. package/dist/esm/components/DataTable/DataTableRoot.mjs +53 -10
  31. package/dist/esm/components/DataTable/DataTableRoot.mjs.map +1 -1
  32. package/dist/esm/components/DataTable/DataTableRoot.native.js +55 -11
  33. package/dist/esm/components/DataTable/DataTableRoot.native.js.map +1 -1
  34. package/dist/esm/components/DataTable/index.native.js +60 -94
  35. package/dist/esm/components/DataTable/index.native.js.map +1 -1
  36. package/dist/esm/components/DataTableFilterButton/index.mjs +14 -1
  37. package/dist/esm/components/DataTableFilterButton/index.mjs.map +1 -1
  38. package/dist/esm/components/DataTableFilterButton/index.native.js +15 -1
  39. package/dist/esm/components/DataTableFilterButton/index.native.js.map +1 -1
  40. package/dist/esm/components/DataTableToolbar/index.mjs +180 -104
  41. package/dist/esm/components/DataTableToolbar/index.mjs.map +1 -1
  42. package/dist/esm/components/DataTableToolbar/index.native.js +189 -112
  43. package/dist/esm/components/DataTableToolbar/index.native.js.map +1 -1
  44. package/dist/esm/hooks/useDataTable.mjs +32 -1
  45. package/dist/esm/hooks/useDataTable.mjs.map +1 -1
  46. package/dist/esm/hooks/useDataTable.native.js +37 -1
  47. package/dist/esm/hooks/useDataTable.native.js.map +1 -1
  48. package/dist/jsx/components/DataTable/DataTableCardControls.mjs +89 -54
  49. package/dist/jsx/components/DataTable/DataTableCardControls.mjs.map +1 -1
  50. package/dist/jsx/components/DataTable/DataTableCardControls.native.js +93 -56
  51. package/dist/jsx/components/DataTable/DataTableCardControls.native.js.map +1 -1
  52. package/dist/jsx/components/DataTable/DataTableMobileCards.mjs +99 -34
  53. package/dist/jsx/components/DataTable/DataTableMobileCards.mjs.map +1 -1
  54. package/dist/jsx/components/DataTable/DataTableMobileCards.native.js +139 -38
  55. package/dist/jsx/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  56. package/dist/jsx/components/DataTable/DataTableRoot.mjs +53 -10
  57. package/dist/jsx/components/DataTable/DataTableRoot.mjs.map +1 -1
  58. package/dist/jsx/components/DataTable/DataTableRoot.native.js +53 -9
  59. package/dist/jsx/components/DataTable/DataTableRoot.native.js.map +1 -1
  60. package/dist/jsx/components/DataTable/index.native.js +59 -93
  61. package/dist/jsx/components/DataTable/index.native.js.map +1 -1
  62. package/dist/jsx/components/DataTableFilterButton/index.mjs +14 -1
  63. package/dist/jsx/components/DataTableFilterButton/index.mjs.map +1 -1
  64. package/dist/jsx/components/DataTableFilterButton/index.native.js +15 -0
  65. package/dist/jsx/components/DataTableFilterButton/index.native.js.map +1 -1
  66. package/dist/jsx/components/DataTableToolbar/index.mjs +180 -104
  67. package/dist/jsx/components/DataTableToolbar/index.mjs.map +1 -1
  68. package/dist/jsx/components/DataTableToolbar/index.native.js +187 -109
  69. package/dist/jsx/components/DataTableToolbar/index.native.js.map +1 -1
  70. package/dist/jsx/hooks/useDataTable.mjs +32 -1
  71. package/dist/jsx/hooks/useDataTable.mjs.map +1 -1
  72. package/dist/jsx/hooks/useDataTable.native.js +37 -1
  73. package/dist/jsx/hooks/useDataTable.native.js.map +1 -1
  74. package/package.json +14 -14
  75. package/src/components/DataTable/DataTable.spec.tsx +5 -3
  76. package/src/components/DataTable/DataTable.stories.tsx +59 -0
  77. package/src/components/DataTable/DataTableCardControls.stories.tsx +61 -2
  78. package/src/components/DataTable/DataTableCardControls.tsx +92 -66
  79. package/src/components/DataTable/DataTableMobileCards.tsx +162 -39
  80. package/src/components/DataTable/DataTableRoot.tsx +82 -14
  81. package/src/components/DataTable/cardLayout.spec.tsx +23 -11
  82. package/src/components/DataTable/cardToolbar.spec.tsx +238 -0
  83. package/src/components/DataTable/chromeInterplay.spec.tsx +7 -5
  84. package/src/components/DataTable/columnWidthLayout.spec.tsx +189 -0
  85. package/src/components/DataTable/dataTableCardControls.spec.tsx +3 -2
  86. package/src/components/DataTable/dataTableMobileCards.spec.tsx +50 -2
  87. package/src/components/DataTable/footerLayout.spec.tsx +250 -0
  88. package/src/components/DataTable/index.native.tsx +80 -124
  89. package/src/components/DataTable/nativeCardParity.spec.tsx +95 -0
  90. package/src/components/DataTable/pinningGeometry.spec.tsx +66 -1
  91. package/src/components/DataTableExport/DataTableExport.spec.tsx +42 -18
  92. package/src/components/DataTableFilterButton/index.tsx +9 -0
  93. package/src/components/DataTableToolbar/index.tsx +184 -105
  94. package/src/hooks/useDataTable.spec.ts +114 -0
  95. package/src/hooks/useDataTable.ts +23 -1
  96. package/src/types.ts +14 -12
  97. package/types/components/DataTable/DataTableCardControls.d.ts +3 -0
  98. package/types/components/DataTable/DataTableCardControls.d.ts.map +1 -1
  99. package/types/components/DataTable/DataTableMobileCards.d.ts +42 -8
  100. package/types/components/DataTable/DataTableMobileCards.d.ts.map +1 -1
  101. package/types/components/DataTable/DataTableRoot.d.ts.map +1 -1
  102. package/types/components/DataTable/index.native.d.ts +1 -1
  103. package/types/components/DataTable/index.native.d.ts.map +1 -1
  104. package/types/components/DataTableFilterButton/index.d.ts +2 -1
  105. package/types/components/DataTableFilterButton/index.d.ts.map +1 -1
  106. package/types/components/DataTableToolbar/index.d.ts +1 -0
  107. package/types/components/DataTableToolbar/index.d.ts.map +1 -1
  108. package/types/hooks/useDataTable.d.ts.map +1 -1
  109. package/types/types.d.ts +14 -12
  110. package/types/types.d.ts.map +1 -1
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { renderWithProviders } from "@multiplatform.one/test-utils";
18
18
  import { fireEvent, waitFor } from "@testing-library/react";
19
- import { describe, expect, it, vi } from "vitest";
19
+ import { afterEach, describe, expect, it, vi } from "vitest";
20
20
  import type { DataTableColumn } from "../../types";
21
21
  import { DataTable } from "./index";
22
22
 
@@ -142,3 +142,68 @@ describe("column pinning geometry", () => {
142
142
  });
143
143
  });
144
144
  });
145
+
146
+ afterEach(() => document.documentElement.removeAttribute("dir"));
147
+
148
+ it.each(["ltr", "rtl"])(
149
+ "keeps declared small pinned boxes and logical offsets aligned in %s",
150
+ async (direction) => {
151
+ document.documentElement.setAttribute("dir", direction);
152
+ const utils = renderPinned({
153
+ columns: columns.map((column, index) => ({
154
+ ...column,
155
+ footer: String(column.header),
156
+ width: index === 0 ? 48 : 320,
157
+ })),
158
+ });
159
+ await pinIdColumn(utils);
160
+ fireEvent.click(utils.getByLabelText("Pin First Name column left"));
161
+ await waitFor(() => {
162
+ const first = utils.container.querySelector("#id-header") as HTMLElement;
163
+ const second = utils.container.querySelector("#firstName-header") as HTMLElement;
164
+ expect(hasStyle(first, "width", "48px", "_w-48px")).toBe(true);
165
+ expect(hasStyle(first, "minWidth", "48px", "_miw-48px")).toBe(true);
166
+ expect(hasStyle(first, "maxWidth", "48px", "_maw-48px")).toBe(true);
167
+ expect(second.style.insetInlineStart).toBe("48px");
168
+ const body = utils.container.querySelector('[role="row"][aria-label="Row 1"]')!;
169
+ const cells = body.querySelectorAll('[role="cell"]');
170
+ expect(hasStyle(cells[0] as HTMLElement, "width", "48px", "_w-48px")).toBe(true);
171
+ expect((cells[1] as HTMLElement).style.insetInlineStart).toBe("48px");
172
+ const footer = utils.container.querySelectorAll('tfoot [role="cell"]');
173
+ expect(hasStyle(footer[0] as HTMLElement, "width", "48px", "_w-48px")).toBe(true);
174
+ expect((footer[1] as HTMLElement).style.insetInlineStart).toBe("48px");
175
+ });
176
+ },
177
+ );
178
+
179
+ it.each(["ltr", "rtl"])(
180
+ "aligns right-pinned headers, rows and footers in %s",
181
+ async (direction) => {
182
+ document.documentElement.setAttribute("dir", direction);
183
+ const utils = renderPinned({
184
+ columns: columns.map((column, index) => ({
185
+ ...column,
186
+ footer: String(column.header),
187
+ width: index === 3 ? 76 : 320,
188
+ })),
189
+ });
190
+ for (const label of ["Last Name", "Age"]) {
191
+ fireEvent.click(utils.getByLabelText(`Pin ${label} column left`));
192
+ fireEvent.click(utils.getByLabelText(`Pin ${label} column right`));
193
+ }
194
+ await waitFor(() => {
195
+ const last = utils.container.querySelector("#age-header") as HTMLElement;
196
+ const previous = utils.container.querySelector("#lastName-header") as HTMLElement;
197
+ expect(hasStyle(last, "width", "76px", "_w-76px")).toBe(true);
198
+ expect(last.style.insetInlineEnd).toBe("0px");
199
+ expect(previous.style.insetInlineEnd).toBe("76px");
200
+ const body = utils.container.querySelector('[role="row"][aria-label="Row 1"]')!;
201
+ const cells = body.querySelectorAll('[role="cell"]');
202
+ expect(hasStyle(cells[3] as HTMLElement, "width", "76px", "_w-76px")).toBe(true);
203
+ expect((cells[2] as HTMLElement).style.insetInlineEnd).toBe("76px");
204
+ const footer = utils.container.querySelectorAll('tfoot [role="cell"]');
205
+ expect(hasStyle(footer[3] as HTMLElement, "width", "76px", "_w-76px")).toBe(true);
206
+ expect((footer[2] as HTMLElement).style.insetInlineEnd).toBe("76px");
207
+ });
208
+ },
209
+ );
@@ -1,4 +1,4 @@
1
- import { fireEvent, screen, waitFor } from "@testing-library/react";
1
+ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
2
2
  import { renderWithProviders } from "@multiplatform.one/test-utils";
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { DataTable } from "../DataTable";
@@ -203,16 +203,30 @@ describe("DataTableExport", () => {
203
203
  });
204
204
 
205
205
  it("shows loading state when export is in progress", async () => {
206
- const mockOnExport = vi.fn(
207
- () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
208
- ) as unknown as ReturnType<typeof vi.fn> & ((options: ExportOptions) => Promise<void>);
206
+ let finishExport!: () => void;
207
+ const pending = new Promise<void>((resolve) => {
208
+ finishExport = resolve;
209
+ });
210
+ const mockOnExport = vi.fn(() => pending) as unknown as ReturnType<typeof vi.fn> &
211
+ ((options: ExportOptions) => Promise<void>);
209
212
 
210
213
  renderWithProviders(
211
214
  <DataTableExport data={mockData} columns={mockColumns} onExport={mockOnExport} />,
212
215
  );
213
216
 
214
- fireEvent.click(screen.getByText("CSV"));
215
- expect(screen.getByText("...")).toBeInTheDocument();
217
+ const exportButton = screen.getByRole("button", { name: /Export as CSV/i });
218
+ try {
219
+ fireEvent.click(exportButton);
220
+ expect(screen.getByText("...")).toBeInTheDocument();
221
+ expect(exportButton).toHaveAttribute("aria-disabled", "true");
222
+ } finally {
223
+ await act(async () => {
224
+ finishExport();
225
+ await pending;
226
+ });
227
+ }
228
+ expect(exportButton).toHaveTextContent("CSV");
229
+ expect(exportButton).not.toHaveAttribute("aria-disabled", "true");
216
230
  });
217
231
 
218
232
  it("disables buttons when disabled prop is true", () => {
@@ -599,9 +613,12 @@ describe("DataTableExport", () => {
599
613
  });
600
614
 
601
615
  it("prevents multiple simultaneous exports", async () => {
602
- const mockOnExport = vi.fn(
603
- () => new Promise<void>((resolve) => setTimeout(resolve, 200)),
604
- ) as unknown as ReturnType<typeof vi.fn> & ((options: ExportOptions) => Promise<void>);
616
+ let finishExport!: () => void;
617
+ const pending = new Promise<void>((resolve) => {
618
+ finishExport = resolve;
619
+ });
620
+ const mockOnExport = vi.fn(() => pending) as unknown as ReturnType<typeof vi.fn> &
621
+ ((options: ExportOptions) => Promise<void>);
605
622
 
606
623
  renderWithProviders(
607
624
  <DataTableExport data={mockData} columns={mockColumns} onExport={mockOnExport} />,
@@ -609,13 +626,22 @@ describe("DataTableExport", () => {
609
626
 
610
627
  // Click export twice quickly - button shows "..." when loading
611
628
  const exportButton = screen.getByRole("button", { name: /Export as CSV/i });
612
- fireEvent.click(exportButton);
613
- fireEvent.click(exportButton);
629
+ try {
630
+ fireEvent.click(exportButton);
631
+ fireEvent.click(exportButton);
614
632
 
615
- // Should only call once
616
- await waitFor(() => {
617
- expect(mockOnExport).toHaveBeenCalledTimes(1);
618
- });
633
+ // Should only call once
634
+ await waitFor(() => {
635
+ expect(mockOnExport).toHaveBeenCalledTimes(1);
636
+ });
637
+ } finally {
638
+ await act(async () => {
639
+ finishExport();
640
+ await pending;
641
+ });
642
+ }
643
+ expect(exportButton).toHaveTextContent("CSV");
644
+ expect(exportButton).not.toHaveAttribute("aria-disabled", "true");
619
645
  });
620
646
 
621
647
  it("surfaces an inline error when Blob URLs are unavailable", async () => {
@@ -629,9 +655,7 @@ describe("DataTableExport", () => {
629
655
  renderWithProviders(<DataTableExport data={mockData} columns={mockColumns} />);
630
656
  fireEvent.click(screen.getByRole("button", { name: /Export as CSV/i }));
631
657
  await waitFor(() => {
632
- expect(screen.getByRole("alert").textContent).toMatch(
633
- /not available on this platform/i,
634
- );
658
+ expect(screen.getByRole("alert").textContent).toMatch(/not available on this platform/i);
635
659
  });
636
660
  expect(mockCreateObjectURL).not.toHaveBeenCalled();
637
661
  } finally {
@@ -91,6 +91,14 @@ function GroupHeader({ children }: { children: string }) {
91
91
  }
92
92
 
93
93
  export function DataTableFilterButton() {
94
+ return <FilterButton />;
95
+ }
96
+
97
+ export function DataTableCardFilterButton() {
98
+ return <FilterButton contentTrigger />;
99
+ }
100
+
101
+ function FilterButton({ contentTrigger = false }: { contentTrigger?: boolean }) {
94
102
  const ctx = useDataTableState();
95
103
  const { knobProps } = useResolvedKnobs();
96
104
  // "Back to field list" points toward the panel's start edge — directional,
@@ -315,6 +323,7 @@ export function DataTableFilterButton() {
315
323
  open={open}
316
324
  onOpenChange={handleOpenChange}
317
325
  trigger={trigger}
326
+ {...(contentTrigger ? { triggerSizing: "content" as const } : {})}
318
327
  fitContent
319
328
  scrollable={false}
320
329
  >
@@ -11,7 +11,7 @@ import type { TableView } from "../../types";
11
11
  import { iconSizeForToken } from "../../utils/chromeIcons";
12
12
  import { useDataTableState } from "../DataTable/DataTableStateContext";
13
13
  import { DataTableExport } from "../DataTableExport";
14
- import { DataTableFilterButton } from "../DataTableFilterButton";
14
+ import { DataTableCardFilterButton, DataTableFilterButton } from "../DataTableFilterButton";
15
15
 
16
16
  export interface DataTableToolbarProps {
17
17
  // Search
@@ -49,6 +49,17 @@ export interface DataTableToolbarProps {
49
49
  }
50
50
 
51
51
  export function DataTableToolbar(props: DataTableToolbarProps) {
52
+ return <ToolbarLayout {...props} />;
53
+ }
54
+
55
+ export function DataTableCardToolbar(props: DataTableToolbarProps) {
56
+ return <ToolbarLayout {...props} cardLayout />;
57
+ }
58
+
59
+ function ToolbarLayout({
60
+ cardLayout = false,
61
+ ...props
62
+ }: DataTableToolbarProps & { cardLayout?: boolean }) {
52
63
  const ctx = useDataTableState();
53
64
 
54
65
  const globalFilter = props.globalFilter ?? ctx?.state.globalFilter ?? "";
@@ -108,9 +119,105 @@ export function DataTableToolbar(props: DataTableToolbarProps) {
108
119
  );
109
120
  const hasTrailing = showActionCluster || isFetching;
110
121
 
122
+ const viewPickerContent = showViewPicker && (
123
+ <DropdownMenu closeOnSelect placement="bottom-end">
124
+ <DropdownMenu.Trigger asChild>
125
+ <RovingFocusGroup.Item focusable asChild>
126
+ <Button
127
+ chromeless
128
+ size={controlSize}
129
+ iconAfter={<CaretDownIcon size={iconPx} />}
130
+ aria-label={t("Saved views")}
131
+ {...(cardLayout ? { minWidth: 0, maxWidth: "100%" as const } : {})}
132
+ aria-haspopup="menu"
133
+ >
134
+ {activeView?.name ?? t("Views")}
135
+ </Button>
136
+ </RovingFocusGroup.Item>
137
+ </DropdownMenu.Trigger>
138
+ <DropdownMenu.Content>
139
+ {views.map((view) => (
140
+ <DropdownMenu.CheckboxItem
141
+ key={view.id}
142
+ checked={view.id === activeViewId}
143
+ closeOnSelect
144
+ onSelect={() => onLoadView(view.id)}
145
+ >
146
+ {view.name}
147
+ </DropdownMenu.CheckboxItem>
148
+ ))}
149
+ </DropdownMenu.Content>
150
+ </DropdownMenu>
151
+ );
152
+ const viewPicker =
153
+ cardLayout && showViewPicker ? (
154
+ <XStack minWidth={0} maxWidth="100%" flexBasis="auto" flexGrow={0} flexShrink={1}>
155
+ {viewPickerContent}
156
+ </XStack>
157
+ ) : (
158
+ viewPickerContent
159
+ );
160
+ const saveView = showSave && (
161
+ <Popover open={showSaveViewDialog} onOpenChange={setShowSaveViewDialog}>
162
+ <Popover.Trigger asChild>
163
+ <RovingFocusGroup.Item focusable asChild>
164
+ <Button
165
+ chromeless
166
+ size={controlSize}
167
+ icon={<FloppyDiskIcon size={iconPx} />}
168
+ aria-label={t("Save view")}
169
+ />
170
+ </RovingFocusGroup.Item>
171
+ </Popover.Trigger>
172
+ <Popover.Content
173
+ {...knobProps.elevatedSurface}
174
+ {...knobProps.borderRadius}
175
+ borderColor="$color6"
176
+ // LC-24: enter/exitStyle need a `transition` driver on the
177
+ // same element ({...string} spread set nothing).
178
+ transition={knobProps.transition}
179
+ enterStyle={{ x: 0, y: -10, opacity: 0 }}
180
+ exitStyle={{ x: 0, y: -10, opacity: 0 }}
181
+ data-dismiss-class="compose"
182
+ >
183
+ <YStack {...knobProps.gap} {...knobProps.panelPadding} minWidth={240}>
184
+ <Input
185
+ placeholder={t("View name")}
186
+ value={newViewName}
187
+ onChangeText={setNewViewName}
188
+ size={searchSize}
189
+ {...(compact === undefined ? {} : { compact })}
190
+ inputProps={{
191
+ "aria-label": t("View name"),
192
+ onKeyDown: (e: React.KeyboardEvent) => {
193
+ if (e.key === "Enter") handleSaveView();
194
+ },
195
+ }}
196
+ />
197
+ <XStack {...knobProps.gap} justifyContent="flex-end">
198
+ <Button size={controlSize} onPress={() => setShowSaveViewDialog(false)}>
199
+ {t("Cancel")}
200
+ </Button>
201
+ <Button
202
+ size={controlSize}
203
+ onPress={handleSaveView}
204
+ disabled={!newViewName.trim()}
205
+ disabledReason={t("Enter a view name")}
206
+ >
207
+ {t("Save")}
208
+ </Button>
209
+ </XStack>
210
+ </YStack>
211
+ </Popover.Content>
212
+ </Popover>
213
+ );
214
+ const showSecondary = showViewPicker || showSave || enableExport || isFetching;
215
+
111
216
  return (
112
217
  <XStack
113
- alignItems="center"
218
+ alignItems={cardLayout ? "stretch" : "center"}
219
+ flexDirection={cardLayout ? "column" : "row"}
220
+ {...(cardLayout ? { width: "100%" as const, minWidth: 0 } : {})}
114
221
  // LC-39: flush band — no painted panel, so no horizontal inset.
115
222
  paddingVertical={knobProps.panelPadding.padding}
116
223
  paddingHorizontal={0}
@@ -124,7 +231,12 @@ export function DataTableToolbar(props: DataTableToolbarProps) {
124
231
  aria-busy={isFetching || undefined}
125
232
  >
126
233
  {enableGlobalSearch && (
127
- <XStack flex={1} minWidth={160} maxWidth={searchMaxWidth} flexShrink={1}>
234
+ <XStack
235
+ flex={cardLayout ? undefined : 1}
236
+ minWidth={cardLayout ? 0 : 160}
237
+ maxWidth={cardLayout ? undefined : searchMaxWidth}
238
+ flexShrink={1}
239
+ >
128
240
  <SearchInput
129
241
  value={globalFilter}
130
242
  onChange={onGlobalFilterChange}
@@ -144,116 +256,83 @@ export function DataTableToolbar(props: DataTableToolbarProps) {
144
256
  </XStack>
145
257
  )}
146
258
 
147
- {hasTrailing && (
148
- <XStack
149
- alignItems="center"
150
- {...knobProps.gap}
151
- flexShrink={0}
152
- marginInlineStart={enableGlobalSearch ? "auto" : 0}
153
- >
154
- {showActionCluster && (
259
+ {cardLayout ? (
260
+ <>
261
+ {(showFilterButton || customActions) && (
155
262
  <RovingFocusGroup orientation="horizontal" loop asChild>
156
- <XStack alignItems="center" {...knobProps.gap}>
157
- {showFilterButton && <DataTableFilterButton />}
158
-
159
- {enableExport && <DataTableExport />}
160
-
263
+ <XStack
264
+ data-table-card-primary=""
265
+ width="100%"
266
+ minWidth={0}
267
+ alignItems="center"
268
+ flexWrap="nowrap"
269
+ {...knobProps.gap}
270
+ >
271
+ {showFilterButton && (
272
+ <XStack flexShrink={0}>
273
+ <DataTableCardFilterButton />
274
+ </XStack>
275
+ )}
161
276
  {customActions}
162
-
163
- {showViewPicker && (
164
- <DropdownMenu closeOnSelect placement="bottom-end">
165
- <DropdownMenu.Trigger asChild>
166
- <RovingFocusGroup.Item focusable asChild>
167
- <Button
168
- chromeless
169
- size={controlSize}
170
- iconAfter={<CaretDownIcon size={iconPx} />}
171
- aria-label={t("Saved views")}
172
- aria-haspopup="menu"
173
- >
174
- {activeView?.name ?? t("Views")}
175
- </Button>
176
- </RovingFocusGroup.Item>
177
- </DropdownMenu.Trigger>
178
- <DropdownMenu.Content>
179
- {views.map((view) => (
180
- <DropdownMenu.CheckboxItem
181
- key={view.id}
182
- checked={view.id === activeViewId}
183
- closeOnSelect
184
- onSelect={() => onLoadView(view.id)}
185
- >
186
- {view.name}
187
- </DropdownMenu.CheckboxItem>
188
- ))}
189
- </DropdownMenu.Content>
190
- </DropdownMenu>
277
+ </XStack>
278
+ </RovingFocusGroup>
279
+ )}
280
+ {showSecondary && (
281
+ <RovingFocusGroup orientation="horizontal" loop asChild>
282
+ <XStack
283
+ data-table-card-secondary=""
284
+ width="100%"
285
+ minWidth={0}
286
+ flexWrap="wrap"
287
+ alignItems="center"
288
+ {...knobProps.gap}
289
+ >
290
+ {enableExport && (
291
+ <XStack maxWidth="100%" minWidth={0}>
292
+ <DataTableExport />
293
+ </XStack>
191
294
  )}
192
-
193
- {showSave && (
194
- <Popover open={showSaveViewDialog} onOpenChange={setShowSaveViewDialog}>
195
- <Popover.Trigger asChild>
196
- <RovingFocusGroup.Item focusable asChild>
197
- <Button
198
- chromeless
199
- size={controlSize}
200
- icon={<FloppyDiskIcon size={iconPx} />}
201
- aria-label={t("Save view")}
202
- />
203
- </RovingFocusGroup.Item>
204
- </Popover.Trigger>
205
- <Popover.Content
206
- {...knobProps.elevatedSurface}
207
- {...knobProps.borderRadius}
208
- borderColor="$color6"
209
- // LC-24: enter/exitStyle need a `transition` driver on the
210
- // same element ({...string} spread set nothing).
211
- transition={knobProps.transition}
212
- enterStyle={{ x: 0, y: -10, opacity: 0 }}
213
- exitStyle={{ x: 0, y: -10, opacity: 0 }}
214
- data-dismiss-class="compose"
215
- >
216
- <YStack {...knobProps.gap} {...knobProps.panelPadding} minWidth={240}>
217
- <Input
218
- placeholder={t("View name")}
219
- value={newViewName}
220
- onChangeText={setNewViewName}
221
- size={searchSize}
222
- {...(compact === undefined ? {} : { compact })}
223
- inputProps={{
224
- "aria-label": t("View name"),
225
- onKeyDown: (e: React.KeyboardEvent) => {
226
- if (e.key === "Enter") handleSaveView();
227
- },
228
- }}
229
- />
230
- <XStack {...knobProps.gap} justifyContent="flex-end">
231
- <Button size={controlSize} onPress={() => setShowSaveViewDialog(false)}>
232
- {t("Cancel")}
233
- </Button>
234
- <Button
235
- size={controlSize}
236
- onPress={handleSaveView}
237
- disabled={!newViewName.trim()}
238
- disabledReason={t("Enter a view name")}
239
- >
240
- {t("Save")}
241
- </Button>
242
- </XStack>
243
- </YStack>
244
- </Popover.Content>
245
- </Popover>
295
+ {viewPicker}
296
+ {saveView}
297
+ {isFetching && (
298
+ <XStack alignItems="center" aria-live="polite" data-table-fetching="true">
299
+ <Spinner size="small" />
300
+ </XStack>
246
301
  )}
247
302
  </XStack>
248
303
  </RovingFocusGroup>
249
304
  )}
305
+ </>
306
+ ) : (
307
+ hasTrailing && (
308
+ <XStack
309
+ alignItems="center"
310
+ {...knobProps.gap}
311
+ flexShrink={0}
312
+ marginInlineStart={enableGlobalSearch ? "auto" : 0}
313
+ >
314
+ {showActionCluster && (
315
+ <RovingFocusGroup orientation="horizontal" loop asChild>
316
+ <XStack alignItems="center" {...knobProps.gap}>
317
+ {showFilterButton && <DataTableFilterButton />}
250
318
 
251
- {isFetching && (
252
- <XStack alignItems="center" aria-live="polite" data-table-fetching="true">
253
- <Spinner size="small" />
254
- </XStack>
255
- )}
256
- </XStack>
319
+ {enableExport && <DataTableExport />}
320
+
321
+ {customActions}
322
+
323
+ {viewPicker}
324
+ {saveView}
325
+ </XStack>
326
+ </RovingFocusGroup>
327
+ )}
328
+
329
+ {isFetching && (
330
+ <XStack alignItems="center" aria-live="polite" data-table-fetching="true">
331
+ <Spinner size="small" />
332
+ </XStack>
333
+ )}
334
+ </XStack>
335
+ )
257
336
  )}
258
337
  </XStack>
259
338
  );
@@ -111,3 +111,117 @@ describe("useDataTable controlledState adoption", () => {
111
111
  expect(result.current.state.filters).toEqual([]);
112
112
  });
113
113
  });
114
+
115
+ describe("useDataTable column width normalization", () => {
116
+ type Row = (typeof data)[number];
117
+
118
+ it.each<[Partial<DataTableColumn<Row>>, number]>([
119
+ [{ width: 320 }, 320],
120
+ [{ width: 320, size: 90 }, 90],
121
+ [{ width: 320, minWidth: 200, maxWidth: 240 }, 240],
122
+ [{ width: 320, size: 90, minWidth: 200, minSize: 60, maxWidth: 80, maxSize: 100 }, 90],
123
+ [{ width: 320, size: 180, maxWidth: 140 }, 140],
124
+ [{ width: 125.5 }, 125.5],
125
+ [{ width: 0 }, 20],
126
+ [{ width: 0, minWidth: 0 }, 0],
127
+ [{ width: 320, size: 0, minSize: 0 }, 0],
128
+ [{ width: Number.NaN }, 150],
129
+ [{ width: Number.POSITIVE_INFINITY }, 150],
130
+ [{ width: -1 }, 150],
131
+ [{ minWidth: Number.NaN, maxWidth: -1 }, 150],
132
+ ])("resolves %j to %s through the real table", (sizing, expected) => {
133
+ const { result } = renderHook(() =>
134
+ useDataTable({ data, columns: [{ id: "name", accessorKey: "name", ...sizing }] }),
135
+ );
136
+ expect(result.current.table.getColumn("name")?.getSize()).toBe(expected);
137
+ });
138
+
139
+ it("preserves input objects and normalizes grouped leaves without distributing group width", () => {
140
+ const leaf: DataTableColumn<Row> = { id: "name", accessorKey: "name", width: 320 };
141
+ const group: DataTableColumn<Row> = {
142
+ id: "group",
143
+ header: "Group",
144
+ width: 900,
145
+ columns: [leaf],
146
+ };
147
+ const plain: DataTableColumn<Row> = { id: "status", accessorKey: "status" };
148
+ Object.freeze(leaf);
149
+ Object.freeze(group.columns);
150
+ Object.freeze(group);
151
+ Object.freeze(plain);
152
+ const source = [group, plain];
153
+ Object.freeze(source);
154
+ const { result, rerender } = renderHook(() => useDataTable({ data, columns: source }));
155
+ expect(result.current.table.getColumn("name")?.getSize()).toBe(320);
156
+ expect(leaf).not.toHaveProperty("size");
157
+ expect(group.columns?.[0]).toBe(leaf);
158
+ expect(result.current.table.options.columns[1]).toBe(plain);
159
+ const normalized = result.current.table.options.columns;
160
+ rerender();
161
+ expect(result.current.table.options.columns).toBe(normalized);
162
+ });
163
+
164
+ it("keeps sizing state ahead of updated declarations and resets to the current definition", () => {
165
+ const { result, rerender } = renderHook(
166
+ ({ width }) => useDataTable({ data, columns: [{ id: "name", accessorKey: "name", width }] }),
167
+ { initialProps: { width: 320 } },
168
+ );
169
+ expect(result.current.table.getColumn("name")?.getSize()).toBe(320);
170
+ act(() => result.current.actions.setColumnSizing({ name: 210 }));
171
+ rerender({ width: 400 });
172
+ expect(result.current.table.getColumn("name")?.getSize()).toBe(210);
173
+ act(() => result.current.actions.resetToDefault());
174
+ expect(result.current.table.getColumn("name")?.getSize()).toBe(400);
175
+ });
176
+
177
+ it("loads existing view width state without renaming its persistence field", () => {
178
+ const { result } = renderHook(() =>
179
+ useDataTable({
180
+ data,
181
+ columns: [{ id: "name", accessorKey: "name", width: 320 }],
182
+ initialState: {
183
+ views: [
184
+ {
185
+ id: "saved",
186
+ name: "Saved",
187
+ columns: { name: { id: "name", visible: true, width: 205, order: 0 } },
188
+ filters: [],
189
+ sorting: [],
190
+ pagination: { pageIndex: 0, pageSize: 10 },
191
+ },
192
+ ],
193
+ },
194
+ }),
195
+ );
196
+ act(() => result.current.actions.loadView("saved"));
197
+ expect(result.current.table.getColumn("name")?.getSize()).toBe(205);
198
+ expect(result.current.state.columnSizing).toEqual({ name: 205 });
199
+ act(() => result.current.actions.resetToDefault());
200
+ expect(result.current.table.getColumn("name")?.getSize()).toBe(320);
201
+ });
202
+
203
+ it("preserves sorting/filter aliases and explicit overrides alongside width", () => {
204
+ const sortingFn = vi.fn(() => 0);
205
+ const { result } = renderHook(() =>
206
+ useDataTable({
207
+ data,
208
+ columns: [
209
+ { id: "name", accessorKey: "name", width: 320, sortable: false, filterable: false },
210
+ {
211
+ id: "status",
212
+ accessorKey: "status",
213
+ width: 90,
214
+ sortable: false,
215
+ enableSorting: true,
216
+ sortingFn,
217
+ },
218
+ ],
219
+ }),
220
+ );
221
+ expect(result.current.table.getColumn("name")?.getSize()).toBe(320);
222
+ expect(result.current.table.getColumn("name")?.getCanSort()).toBe(false);
223
+ expect(result.current.table.getColumn("name")?.getCanFilter()).toBe(false);
224
+ expect(result.current.table.getColumn("status")?.getCanSort()).toBe(true);
225
+ expect(result.current.table.getColumn("status")?.columnDef.sortingFn).toBe(sortingFn);
226
+ });
227
+ });