@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.
- package/README.md +60 -0
- package/dist/cjs/components/DataTable/DataTableCardControls.cjs +90 -54
- package/dist/cjs/components/DataTable/DataTableCardControls.native.js +93 -56
- package/dist/cjs/components/DataTable/DataTableCardControls.native.js.map +1 -1
- package/dist/cjs/components/DataTable/DataTableMobileCards.cjs +100 -33
- package/dist/cjs/components/DataTable/DataTableMobileCards.native.js +139 -38
- package/dist/cjs/components/DataTable/DataTableMobileCards.native.js.map +1 -1
- package/dist/cjs/components/DataTable/DataTableRoot.cjs +51 -8
- package/dist/cjs/components/DataTable/DataTableRoot.native.js +53 -9
- package/dist/cjs/components/DataTable/DataTableRoot.native.js.map +1 -1
- package/dist/cjs/components/DataTable/index.native.js +59 -93
- package/dist/cjs/components/DataTable/index.native.js.map +1 -1
- package/dist/cjs/components/DataTableFilterButton/index.cjs +14 -0
- package/dist/cjs/components/DataTableFilterButton/index.native.js +15 -0
- package/dist/cjs/components/DataTableFilterButton/index.native.js.map +1 -1
- package/dist/cjs/components/DataTableToolbar/index.cjs +178 -101
- package/dist/cjs/components/DataTableToolbar/index.native.js +187 -109
- package/dist/cjs/components/DataTableToolbar/index.native.js.map +1 -1
- package/dist/cjs/hooks/useDataTable.cjs +32 -1
- package/dist/cjs/hooks/useDataTable.native.js +37 -1
- package/dist/cjs/hooks/useDataTable.native.js.map +1 -1
- package/dist/esm/components/DataTable/DataTableCardControls.mjs +89 -54
- package/dist/esm/components/DataTable/DataTableCardControls.mjs.map +1 -1
- package/dist/esm/components/DataTable/DataTableCardControls.native.js +92 -56
- package/dist/esm/components/DataTable/DataTableCardControls.native.js.map +1 -1
- package/dist/esm/components/DataTable/DataTableMobileCards.mjs +99 -34
- package/dist/esm/components/DataTable/DataTableMobileCards.mjs.map +1 -1
- package/dist/esm/components/DataTable/DataTableMobileCards.native.js +138 -39
- package/dist/esm/components/DataTable/DataTableMobileCards.native.js.map +1 -1
- package/dist/esm/components/DataTable/DataTableRoot.mjs +53 -10
- package/dist/esm/components/DataTable/DataTableRoot.mjs.map +1 -1
- package/dist/esm/components/DataTable/DataTableRoot.native.js +55 -11
- package/dist/esm/components/DataTable/DataTableRoot.native.js.map +1 -1
- package/dist/esm/components/DataTable/index.native.js +60 -94
- package/dist/esm/components/DataTable/index.native.js.map +1 -1
- package/dist/esm/components/DataTableFilterButton/index.mjs +14 -1
- package/dist/esm/components/DataTableFilterButton/index.mjs.map +1 -1
- package/dist/esm/components/DataTableFilterButton/index.native.js +15 -1
- package/dist/esm/components/DataTableFilterButton/index.native.js.map +1 -1
- package/dist/esm/components/DataTableToolbar/index.mjs +180 -104
- package/dist/esm/components/DataTableToolbar/index.mjs.map +1 -1
- package/dist/esm/components/DataTableToolbar/index.native.js +189 -112
- package/dist/esm/components/DataTableToolbar/index.native.js.map +1 -1
- package/dist/esm/hooks/useDataTable.mjs +32 -1
- package/dist/esm/hooks/useDataTable.mjs.map +1 -1
- package/dist/esm/hooks/useDataTable.native.js +37 -1
- package/dist/esm/hooks/useDataTable.native.js.map +1 -1
- package/dist/jsx/components/DataTable/DataTableCardControls.mjs +89 -54
- package/dist/jsx/components/DataTable/DataTableCardControls.mjs.map +1 -1
- package/dist/jsx/components/DataTable/DataTableCardControls.native.js +93 -56
- package/dist/jsx/components/DataTable/DataTableCardControls.native.js.map +1 -1
- package/dist/jsx/components/DataTable/DataTableMobileCards.mjs +99 -34
- package/dist/jsx/components/DataTable/DataTableMobileCards.mjs.map +1 -1
- package/dist/jsx/components/DataTable/DataTableMobileCards.native.js +139 -38
- package/dist/jsx/components/DataTable/DataTableMobileCards.native.js.map +1 -1
- package/dist/jsx/components/DataTable/DataTableRoot.mjs +53 -10
- package/dist/jsx/components/DataTable/DataTableRoot.mjs.map +1 -1
- package/dist/jsx/components/DataTable/DataTableRoot.native.js +53 -9
- package/dist/jsx/components/DataTable/DataTableRoot.native.js.map +1 -1
- package/dist/jsx/components/DataTable/index.native.js +59 -93
- package/dist/jsx/components/DataTable/index.native.js.map +1 -1
- package/dist/jsx/components/DataTableFilterButton/index.mjs +14 -1
- package/dist/jsx/components/DataTableFilterButton/index.mjs.map +1 -1
- package/dist/jsx/components/DataTableFilterButton/index.native.js +15 -0
- package/dist/jsx/components/DataTableFilterButton/index.native.js.map +1 -1
- package/dist/jsx/components/DataTableToolbar/index.mjs +180 -104
- package/dist/jsx/components/DataTableToolbar/index.mjs.map +1 -1
- package/dist/jsx/components/DataTableToolbar/index.native.js +187 -109
- package/dist/jsx/components/DataTableToolbar/index.native.js.map +1 -1
- package/dist/jsx/hooks/useDataTable.mjs +32 -1
- package/dist/jsx/hooks/useDataTable.mjs.map +1 -1
- package/dist/jsx/hooks/useDataTable.native.js +37 -1
- package/dist/jsx/hooks/useDataTable.native.js.map +1 -1
- package/package.json +14 -14
- package/src/components/DataTable/DataTable.spec.tsx +5 -3
- package/src/components/DataTable/DataTable.stories.tsx +59 -0
- package/src/components/DataTable/DataTableCardControls.stories.tsx +61 -2
- package/src/components/DataTable/DataTableCardControls.tsx +92 -66
- package/src/components/DataTable/DataTableMobileCards.tsx +162 -39
- package/src/components/DataTable/DataTableRoot.tsx +82 -14
- package/src/components/DataTable/cardLayout.spec.tsx +23 -11
- package/src/components/DataTable/cardToolbar.spec.tsx +238 -0
- package/src/components/DataTable/chromeInterplay.spec.tsx +7 -5
- package/src/components/DataTable/columnWidthLayout.spec.tsx +189 -0
- package/src/components/DataTable/dataTableCardControls.spec.tsx +3 -2
- package/src/components/DataTable/dataTableMobileCards.spec.tsx +50 -2
- package/src/components/DataTable/footerLayout.spec.tsx +250 -0
- package/src/components/DataTable/index.native.tsx +80 -124
- package/src/components/DataTable/nativeCardParity.spec.tsx +95 -0
- package/src/components/DataTable/pinningGeometry.spec.tsx +66 -1
- package/src/components/DataTableExport/DataTableExport.spec.tsx +42 -18
- package/src/components/DataTableFilterButton/index.tsx +9 -0
- package/src/components/DataTableToolbar/index.tsx +184 -105
- package/src/hooks/useDataTable.spec.ts +114 -0
- package/src/hooks/useDataTable.ts +23 -1
- package/src/types.ts +14 -12
- package/types/components/DataTable/DataTableCardControls.d.ts +3 -0
- package/types/components/DataTable/DataTableCardControls.d.ts.map +1 -1
- package/types/components/DataTable/DataTableMobileCards.d.ts +42 -8
- package/types/components/DataTable/DataTableMobileCards.d.ts.map +1 -1
- package/types/components/DataTable/DataTableRoot.d.ts.map +1 -1
- package/types/components/DataTable/index.native.d.ts +1 -1
- package/types/components/DataTable/index.native.d.ts.map +1 -1
- package/types/components/DataTableFilterButton/index.d.ts +2 -1
- package/types/components/DataTableFilterButton/index.d.ts.map +1 -1
- package/types/components/DataTableToolbar/index.d.ts +1 -0
- package/types/components/DataTableToolbar/index.d.ts.map +1 -1
- package/types/hooks/useDataTable.d.ts.map +1 -1
- package/types/types.d.ts +14 -12
- 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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
613
|
-
|
|
629
|
+
try {
|
|
630
|
+
fireEvent.click(exportButton);
|
|
631
|
+
fireEvent.click(exportButton);
|
|
614
632
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
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
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
{
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
{
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
});
|