@purpurds/table 8.3.1 → 8.5.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/dist/LICENSE.txt +205 -35
- package/dist/drag-indicator-circle.d.ts +13 -0
- package/dist/drag-indicator-circle.d.ts.map +1 -0
- package/dist/draggable-table.d.ts +23 -0
- package/dist/draggable-table.d.ts.map +1 -0
- package/dist/empty-table.d.ts +14 -0
- package/dist/empty-table.d.ts.map +1 -0
- package/dist/loading-table-rows.d.ts +13 -0
- package/dist/loading-table-rows.d.ts.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/table-body.d.ts +2 -2
- package/dist/table-body.d.ts.map +1 -1
- package/dist/table-column-header-cell.d.ts +15 -2
- package/dist/table-column-header-cell.d.ts.map +1 -1
- package/dist/table-content.d.ts +42 -0
- package/dist/table-content.d.ts.map +1 -0
- package/dist/table-headers.d.ts +28 -0
- package/dist/table-headers.d.ts.map +1 -0
- package/dist/table-row-cell-skeleton.d.ts +1 -1
- package/dist/table-row-cell-skeleton.d.ts.map +1 -1
- package/dist/table-row-cell.d.ts +5 -2
- package/dist/table-row-cell.d.ts.map +1 -1
- package/dist/table-row.d.ts +2 -2
- package/dist/table-row.d.ts.map +1 -1
- package/dist/table-settings-drawer.d.ts +44 -11
- package/dist/table-settings-drawer.d.ts.map +1 -1
- package/dist/table.cjs.js +89 -85
- package/dist/table.cjs.js.map +1 -1
- package/dist/table.d.ts +3 -3
- package/dist/table.d.ts.map +1 -1
- package/dist/table.es.js +14040 -9810
- package/dist/table.es.js.map +1 -1
- package/dist/test-utils/helpers.d.ts +1 -0
- package/dist/test-utils/helpers.d.ts.map +1 -1
- package/dist/types.d.ts +23 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/use-drag-handle.hook.d.ts +15 -0
- package/dist/use-drag-handle.hook.d.ts.map +1 -0
- package/dist/use-drag-indicator-position.hook.d.ts +19 -0
- package/dist/use-drag-indicator-position.hook.d.ts.map +1 -0
- package/dist/use-drop-indicator.hook.d.ts +15 -0
- package/dist/use-drop-indicator.hook.d.ts.map +1 -0
- package/dist/use-element-visibility.hook.d.ts +4 -0
- package/dist/use-element-visibility.hook.d.ts.map +1 -0
- package/dist/use-table-scroll.hook.d.ts +6 -0
- package/dist/use-table-scroll.hook.d.ts.map +1 -0
- package/dist/utils/custom-keyboard-coordinates.d.ts +8 -0
- package/dist/utils/custom-keyboard-coordinates.d.ts.map +1 -0
- package/package.json +27 -23
- package/src/drag-indicator-circle.tsx +36 -0
- package/src/draggable-table.test.tsx +381 -0
- package/src/draggable-table.tsx +191 -0
- package/src/empty-table.tsx +54 -0
- package/src/loading-table-rows.tsx +41 -0
- package/src/table-body.tsx +1 -3
- package/src/table-column-header-cell.tsx +135 -64
- package/src/table-content-drag.test.tsx +505 -0
- package/src/table-content.tsx +165 -0
- package/src/table-dnd-integration.test.tsx +425 -0
- package/src/table-drag-and-drop.test.tsx +276 -0
- package/src/table-headers.tsx +118 -0
- package/src/table-row-cell-skeleton.tsx +1 -1
- package/src/table-row-cell.test.tsx +2 -1
- package/src/table-row-cell.tsx +42 -31
- package/src/table-row.tsx +1 -3
- package/src/table-settings-drawer.module.scss +165 -2
- package/src/table-settings-drawer.test.tsx +0 -99
- package/src/table-settings-drawer.tsx +359 -53
- package/src/table.module.scss +191 -30
- package/src/table.stories.tsx +60 -4
- package/src/table.test.tsx +5 -1
- package/src/table.tsx +255 -213
- package/src/test-utils/helpers.ts +2 -0
- package/src/types.ts +25 -2
- package/src/use-drag-handle.hook.tsx +60 -0
- package/src/use-drag-handle.test.tsx +380 -0
- package/src/use-drag-indicator-position.hook.ts +74 -0
- package/src/use-drop-indicator.hook.ts +46 -0
- package/src/use-element-visibility.hook.ts +28 -0
- package/src/use-table-scroll.hook.tsx +30 -0
- package/src/utils/custom-keyboard-coordinates.ts +83 -0
- package/vitest.setup.ts +1 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { IconDragHorizontal } from "@purpurds/icon/drag-horizontal";
|
|
3
|
+
import c from "classnames/bind";
|
|
4
|
+
|
|
5
|
+
import styles from "./table.module.scss";
|
|
6
|
+
|
|
7
|
+
const cx = c.bind(styles);
|
|
8
|
+
const rootClassName = "purpur-table-column-header-cell";
|
|
9
|
+
|
|
10
|
+
export function useDragHandle() {
|
|
11
|
+
const [mouseDownActive, setMouseDownActive] = useState(false);
|
|
12
|
+
|
|
13
|
+
const handleMouseDown = () => {
|
|
14
|
+
setMouseDownActive(true);
|
|
15
|
+
window.addEventListener("mouseup", handleMouseUp, { once: true });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const handleMouseUp = () => {
|
|
19
|
+
setMouseDownActive(false);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return { mouseDownActive, handleMouseDown };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type TableColumnDragHandleProps = {
|
|
26
|
+
onMouseDown: () => void;
|
|
27
|
+
overlayActive?: boolean;
|
|
28
|
+
isFirstColumn: boolean;
|
|
29
|
+
isLastColumn: boolean;
|
|
30
|
+
columnDragAriaLabel: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function TableColumnDragHandle({
|
|
34
|
+
onMouseDown,
|
|
35
|
+
overlayActive,
|
|
36
|
+
isFirstColumn,
|
|
37
|
+
isLastColumn,
|
|
38
|
+
columnDragAriaLabel,
|
|
39
|
+
}: TableColumnDragHandleProps) {
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className={cx(`${rootClassName}__drag-handle`, {
|
|
43
|
+
[`${rootClassName}__border-radius-first-cell`]: isFirstColumn && !overlayActive,
|
|
44
|
+
[`${rootClassName}__border-radius-last-cell`]: isLastColumn && !overlayActive,
|
|
45
|
+
[`${rootClassName}__drag-handle--active`]: overlayActive,
|
|
46
|
+
})}
|
|
47
|
+
role="button"
|
|
48
|
+
tabIndex={0}
|
|
49
|
+
aria-label={columnDragAriaLabel}
|
|
50
|
+
onMouseDown={onMouseDown}
|
|
51
|
+
onKeyDown={(e) => {
|
|
52
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
53
|
+
onMouseDown();
|
|
54
|
+
}
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<IconDragHorizontal className={cx(`${rootClassName}__drag-handle-icon`)} size="sm" />
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { axe } from "vitest-axe";
|
|
5
|
+
|
|
6
|
+
import { TableColumnDragHandle, useDragHandle } from "./use-drag-handle.hook";
|
|
7
|
+
|
|
8
|
+
// Test component to test the hook
|
|
9
|
+
function TestDragHandleComponent() {
|
|
10
|
+
const { mouseDownActive, handleMouseDown } = useDragHandle();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div>
|
|
14
|
+
<span data-testid="mouse-down-state">{mouseDownActive ? "active" : "inactive"}</span>
|
|
15
|
+
<button onClick={handleMouseDown} data-testid="trigger-button">
|
|
16
|
+
Trigger Mouse Down
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("useDragHandle Hook", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
// Clean up any event listeners
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
// Clean up global event listeners
|
|
30
|
+
const events = ["mouseup"];
|
|
31
|
+
events.forEach((event) => {
|
|
32
|
+
window.removeEventListener(event, () => {});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should initialize with mouseDownActive as false", () => {
|
|
37
|
+
render(<TestDragHandleComponent />);
|
|
38
|
+
|
|
39
|
+
const stateElement = screen.getByTestId("mouse-down-state");
|
|
40
|
+
expect(stateElement).toHaveTextContent("inactive");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should set mouseDownActive to true when handleMouseDown is called", async () => {
|
|
44
|
+
const user = userEvent.setup();
|
|
45
|
+
render(<TestDragHandleComponent />);
|
|
46
|
+
|
|
47
|
+
const triggerButton = screen.getByTestId("trigger-button");
|
|
48
|
+
const stateElement = screen.getByTestId("mouse-down-state");
|
|
49
|
+
|
|
50
|
+
expect(stateElement).toHaveTextContent("inactive");
|
|
51
|
+
|
|
52
|
+
await user.click(triggerButton);
|
|
53
|
+
|
|
54
|
+
expect(stateElement).toHaveTextContent("active");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should reset mouseDownActive to false when mouse up event occurs", async () => {
|
|
58
|
+
const user = userEvent.setup();
|
|
59
|
+
render(<TestDragHandleComponent />);
|
|
60
|
+
|
|
61
|
+
const triggerButton = screen.getByTestId("trigger-button");
|
|
62
|
+
const stateElement = screen.getByTestId("mouse-down-state");
|
|
63
|
+
|
|
64
|
+
// Trigger mouse down
|
|
65
|
+
await user.click(triggerButton);
|
|
66
|
+
expect(stateElement).toHaveTextContent("active");
|
|
67
|
+
|
|
68
|
+
// Simulate mouse up on window
|
|
69
|
+
fireEvent.mouseUp(window);
|
|
70
|
+
|
|
71
|
+
expect(stateElement).toHaveTextContent("inactive");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should add and remove mouseup event listener correctly", async () => {
|
|
75
|
+
const addEventListenerSpy = vi.spyOn(window, "addEventListener");
|
|
76
|
+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
|
77
|
+
|
|
78
|
+
const user = userEvent.setup();
|
|
79
|
+
render(<TestDragHandleComponent />);
|
|
80
|
+
|
|
81
|
+
const triggerButton = screen.getByTestId("trigger-button");
|
|
82
|
+
|
|
83
|
+
await user.click(triggerButton);
|
|
84
|
+
|
|
85
|
+
expect(addEventListenerSpy).toHaveBeenCalledWith("mouseup", expect.any(Function), {
|
|
86
|
+
once: true,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Trigger mouseup to remove listener
|
|
90
|
+
fireEvent.mouseUp(window);
|
|
91
|
+
|
|
92
|
+
addEventListenerSpy.mockRestore();
|
|
93
|
+
removeEventListenerSpy.mockRestore();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("TableColumnDragHandle Component", () => {
|
|
98
|
+
const defaultProps = {
|
|
99
|
+
onMouseDown: vi.fn(),
|
|
100
|
+
overlayActive: false,
|
|
101
|
+
isFirstColumn: false,
|
|
102
|
+
isLastColumn: false,
|
|
103
|
+
columnDragAriaLabel: "Drag to reorder column",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
vi.clearAllMocks();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("Rendering", () => {
|
|
111
|
+
it("should render drag handle with proper structure", () => {
|
|
112
|
+
render(<TableColumnDragHandle {...defaultProps} />);
|
|
113
|
+
|
|
114
|
+
const dragHandle = screen.getByRole("button");
|
|
115
|
+
expect(dragHandle).toBeInTheDocument();
|
|
116
|
+
expect(dragHandle).toHaveAttribute("tabIndex", "0");
|
|
117
|
+
expect(dragHandle).toHaveAttribute("aria-label", defaultProps.columnDragAriaLabel);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should contain drag icon", () => {
|
|
121
|
+
render(<TableColumnDragHandle {...defaultProps} />);
|
|
122
|
+
|
|
123
|
+
// The icon should be present (we're looking for the svg element or icon component)
|
|
124
|
+
const dragHandle = screen.getByRole("button");
|
|
125
|
+
expect(dragHandle).toBeInTheDocument();
|
|
126
|
+
|
|
127
|
+
// Check for the presence of the icon by looking for its class or content
|
|
128
|
+
const icon = dragHandle.querySelector("svg");
|
|
129
|
+
expect(icon).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should be accessible", async () => {
|
|
133
|
+
const { container } = render(<TableColumnDragHandle {...defaultProps} />);
|
|
134
|
+
|
|
135
|
+
const results = await axe(container);
|
|
136
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
137
|
+
// @ts-ignore
|
|
138
|
+
expect(results).toHaveNoViolations();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("CSS Classes", () => {
|
|
143
|
+
it("should have base drag handle class", () => {
|
|
144
|
+
render(<TableColumnDragHandle {...defaultProps} />);
|
|
145
|
+
|
|
146
|
+
const dragHandle = screen.getByRole("button");
|
|
147
|
+
expect(dragHandle.className).toMatch(/drag-handle/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should apply first column border radius class when isFirstColumn is true", () => {
|
|
151
|
+
render(<TableColumnDragHandle {...defaultProps} isFirstColumn={true} />);
|
|
152
|
+
|
|
153
|
+
const dragHandle = screen.getByRole("button");
|
|
154
|
+
expect(dragHandle.className).toMatch(/border-radius-first-cell/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should apply last column border radius class when isLastColumn is true", () => {
|
|
158
|
+
render(<TableColumnDragHandle {...defaultProps} isLastColumn={true} />);
|
|
159
|
+
|
|
160
|
+
const dragHandle = screen.getByRole("button");
|
|
161
|
+
expect(dragHandle.className).toMatch(/border-radius-last-cell/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should apply active class when overlayActive is true", () => {
|
|
165
|
+
render(<TableColumnDragHandle {...defaultProps} overlayActive={true} />);
|
|
166
|
+
|
|
167
|
+
const dragHandle = screen.getByRole("button");
|
|
168
|
+
expect(dragHandle.className).toMatch(/drag-handle--active/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("should not apply border radius classes when overlayActive is true", () => {
|
|
172
|
+
render(
|
|
173
|
+
<TableColumnDragHandle
|
|
174
|
+
{...defaultProps}
|
|
175
|
+
overlayActive={true}
|
|
176
|
+
isFirstColumn={true}
|
|
177
|
+
isLastColumn={true}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const dragHandle = screen.getByRole("button");
|
|
182
|
+
expect(dragHandle).not.toHaveClass(
|
|
183
|
+
"purpur-table-column-header-cell__border-radius-first-cell"
|
|
184
|
+
);
|
|
185
|
+
expect(dragHandle).not.toHaveClass(
|
|
186
|
+
"purpur-table-column-header-cell__border-radius-last-cell"
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("should apply both first and last column classes when both props are true", () => {
|
|
191
|
+
render(<TableColumnDragHandle {...defaultProps} isFirstColumn={true} isLastColumn={true} />);
|
|
192
|
+
|
|
193
|
+
const dragHandle = screen.getByRole("button");
|
|
194
|
+
expect(dragHandle.className).toMatch(/border-radius-first-cell/);
|
|
195
|
+
expect(dragHandle.className).toMatch(/border-radius-last-cell/);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("Event Handling", () => {
|
|
200
|
+
it("should call onMouseDown when clicked", async () => {
|
|
201
|
+
const user = userEvent.setup();
|
|
202
|
+
const onMouseDownMock = vi.fn();
|
|
203
|
+
|
|
204
|
+
render(<TableColumnDragHandle {...defaultProps} onMouseDown={onMouseDownMock} />);
|
|
205
|
+
|
|
206
|
+
const dragHandle = screen.getByRole("button");
|
|
207
|
+
await user.click(dragHandle);
|
|
208
|
+
|
|
209
|
+
expect(onMouseDownMock).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should call onMouseDown when mouse down event is triggered", () => {
|
|
213
|
+
const onMouseDownMock = vi.fn();
|
|
214
|
+
|
|
215
|
+
render(<TableColumnDragHandle {...defaultProps} onMouseDown={onMouseDownMock} />);
|
|
216
|
+
|
|
217
|
+
const dragHandle = screen.getByRole("button");
|
|
218
|
+
fireEvent.mouseDown(dragHandle);
|
|
219
|
+
|
|
220
|
+
expect(onMouseDownMock).toHaveBeenCalledTimes(1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("should call onMouseDown when Enter key is pressed", async () => {
|
|
224
|
+
const user = userEvent.setup();
|
|
225
|
+
const onMouseDownMock = vi.fn();
|
|
226
|
+
|
|
227
|
+
render(<TableColumnDragHandle {...defaultProps} onMouseDown={onMouseDownMock} />);
|
|
228
|
+
|
|
229
|
+
const dragHandle = screen.getByRole("button");
|
|
230
|
+
dragHandle.focus();
|
|
231
|
+
await user.keyboard("{Enter}");
|
|
232
|
+
|
|
233
|
+
expect(onMouseDownMock).toHaveBeenCalledTimes(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should call onMouseDown when Space key is pressed", async () => {
|
|
237
|
+
const user = userEvent.setup();
|
|
238
|
+
const onMouseDownMock = vi.fn();
|
|
239
|
+
|
|
240
|
+
render(<TableColumnDragHandle {...defaultProps} onMouseDown={onMouseDownMock} />);
|
|
241
|
+
|
|
242
|
+
const dragHandle = screen.getByRole("button");
|
|
243
|
+
dragHandle.focus();
|
|
244
|
+
await user.keyboard(" ");
|
|
245
|
+
|
|
246
|
+
expect(onMouseDownMock).toHaveBeenCalledTimes(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should not call onMouseDown for other key presses", async () => {
|
|
250
|
+
const user = userEvent.setup();
|
|
251
|
+
const onMouseDownMock = vi.fn();
|
|
252
|
+
|
|
253
|
+
render(<TableColumnDragHandle {...defaultProps} onMouseDown={onMouseDownMock} />);
|
|
254
|
+
|
|
255
|
+
const dragHandle = screen.getByRole("button");
|
|
256
|
+
dragHandle.focus();
|
|
257
|
+
await user.keyboard("{Escape}");
|
|
258
|
+
|
|
259
|
+
expect(onMouseDownMock).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should handle multiple rapid clicks", async () => {
|
|
263
|
+
const user = userEvent.setup();
|
|
264
|
+
const onMouseDownMock = vi.fn();
|
|
265
|
+
|
|
266
|
+
render(<TableColumnDragHandle {...defaultProps} onMouseDown={onMouseDownMock} />);
|
|
267
|
+
|
|
268
|
+
const dragHandle = screen.getByRole("button");
|
|
269
|
+
|
|
270
|
+
await user.click(dragHandle);
|
|
271
|
+
await user.click(dragHandle);
|
|
272
|
+
await user.click(dragHandle);
|
|
273
|
+
|
|
274
|
+
expect(onMouseDownMock).toHaveBeenCalledTimes(3);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("Accessibility", () => {
|
|
279
|
+
it("should have proper ARIA attributes", () => {
|
|
280
|
+
const customAriaLabel = "Custom drag handle";
|
|
281
|
+
render(<TableColumnDragHandle {...defaultProps} columnDragAriaLabel={customAriaLabel} />);
|
|
282
|
+
|
|
283
|
+
const dragHandle = screen.getByRole("button");
|
|
284
|
+
expect(dragHandle).toHaveAttribute("aria-label", customAriaLabel);
|
|
285
|
+
expect(dragHandle).toHaveAttribute("tabIndex", "0");
|
|
286
|
+
expect(dragHandle).toHaveAttribute("role", "button");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("should be focusable", () => {
|
|
290
|
+
render(<TableColumnDragHandle {...defaultProps} />);
|
|
291
|
+
|
|
292
|
+
const dragHandle = screen.getByRole("button");
|
|
293
|
+
dragHandle.focus();
|
|
294
|
+
|
|
295
|
+
expect(dragHandle).toHaveFocus();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("should support keyboard navigation", async () => {
|
|
299
|
+
const user = userEvent.setup();
|
|
300
|
+
render(
|
|
301
|
+
<div>
|
|
302
|
+
<button>Previous Element</button>
|
|
303
|
+
<TableColumnDragHandle {...defaultProps} />
|
|
304
|
+
<button>Next Element</button>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const dragHandle = screen.getByRole("button", { name: defaultProps.columnDragAriaLabel });
|
|
309
|
+
|
|
310
|
+
// Should be able to tab to the drag handle
|
|
311
|
+
await user.tab();
|
|
312
|
+
await user.tab();
|
|
313
|
+
|
|
314
|
+
expect(dragHandle).toHaveFocus();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("should maintain accessibility with different states", async () => {
|
|
318
|
+
const testCases = [
|
|
319
|
+
{ overlayActive: true, isFirstColumn: true, isLastColumn: false },
|
|
320
|
+
{ overlayActive: false, isFirstColumn: false, isLastColumn: true },
|
|
321
|
+
{ overlayActive: true, isFirstColumn: true, isLastColumn: true },
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
for (const testCase of testCases) {
|
|
325
|
+
const { container, unmount } = render(
|
|
326
|
+
<TableColumnDragHandle {...defaultProps} {...testCase} />
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const results = await axe(container);
|
|
330
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
331
|
+
// @ts-ignore
|
|
332
|
+
expect(results).toHaveNoViolations();
|
|
333
|
+
|
|
334
|
+
unmount();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("Props Validation", () => {
|
|
340
|
+
it("should handle undefined onMouseDown gracefully", () => {
|
|
341
|
+
expect(() => {
|
|
342
|
+
render(
|
|
343
|
+
<TableColumnDragHandle
|
|
344
|
+
{...defaultProps}
|
|
345
|
+
onMouseDown={
|
|
346
|
+
undefined as unknown as Parameters<typeof TableColumnDragHandle>[0]["onMouseDown"]
|
|
347
|
+
}
|
|
348
|
+
/>
|
|
349
|
+
);
|
|
350
|
+
}).not.toThrow();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("should handle empty aria label", () => {
|
|
354
|
+
render(<TableColumnDragHandle {...defaultProps} columnDragAriaLabel="" />);
|
|
355
|
+
|
|
356
|
+
const dragHandle = screen.getByRole("button");
|
|
357
|
+
expect(dragHandle).toHaveAttribute("aria-label", "");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("should handle all boolean combinations", () => {
|
|
361
|
+
const booleanCombinations = [
|
|
362
|
+
{ overlayActive: false, isFirstColumn: false, isLastColumn: false },
|
|
363
|
+
{ overlayActive: false, isFirstColumn: false, isLastColumn: true },
|
|
364
|
+
{ overlayActive: false, isFirstColumn: true, isLastColumn: false },
|
|
365
|
+
{ overlayActive: false, isFirstColumn: true, isLastColumn: true },
|
|
366
|
+
{ overlayActive: true, isFirstColumn: false, isLastColumn: false },
|
|
367
|
+
{ overlayActive: true, isFirstColumn: false, isLastColumn: true },
|
|
368
|
+
{ overlayActive: true, isFirstColumn: true, isLastColumn: false },
|
|
369
|
+
{ overlayActive: true, isFirstColumn: true, isLastColumn: true },
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
booleanCombinations.forEach((combination) => {
|
|
373
|
+
expect(() => {
|
|
374
|
+
const { unmount } = render(<TableColumnDragHandle {...defaultProps} {...combination} />);
|
|
375
|
+
unmount();
|
|
376
|
+
}).not.toThrow();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { DropIndicatorPosition } from "./use-drop-indicator.hook";
|
|
4
|
+
|
|
5
|
+
export interface DragIndicatorPosition {
|
|
6
|
+
top: number;
|
|
7
|
+
left: number;
|
|
8
|
+
container?: HTMLElement;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UseDragIndicatorPositionProps {
|
|
12
|
+
elementRef: RefObject<HTMLElement | null>;
|
|
13
|
+
dropIndicatorPosition: DropIndicatorPosition | null | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Calculates the position for the circular drag indicator rendered via portal.
|
|
18
|
+
* Only circle mode is retained; line indicator logic moved to CSS pseudo-elements.
|
|
19
|
+
*/
|
|
20
|
+
export function useDragIndicatorPosition({
|
|
21
|
+
elementRef,
|
|
22
|
+
dropIndicatorPosition,
|
|
23
|
+
}: UseDragIndicatorPositionProps): DragIndicatorPosition | null {
|
|
24
|
+
const [position, setPosition] = useState<DragIndicatorPosition | null>(null);
|
|
25
|
+
|
|
26
|
+
useLayoutEffect(() => {
|
|
27
|
+
if (!dropIndicatorPosition || !elementRef.current) {
|
|
28
|
+
setPosition(null);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const calculatePosition = () => {
|
|
33
|
+
if (!elementRef.current) return;
|
|
34
|
+
|
|
35
|
+
const rect = elementRef.current.getBoundingClientRect();
|
|
36
|
+
const tableContainer = elementRef.current.closest(
|
|
37
|
+
".purpur-table__container"
|
|
38
|
+
) as HTMLElement | null;
|
|
39
|
+
|
|
40
|
+
const offsetLeft = 2.5;
|
|
41
|
+
const offsetRight = 1.5;
|
|
42
|
+
|
|
43
|
+
if (tableContainer) {
|
|
44
|
+
const containerRect = tableContainer.getBoundingClientRect();
|
|
45
|
+
const left =
|
|
46
|
+
dropIndicatorPosition === "before"
|
|
47
|
+
? rect.left - containerRect.left + tableContainer.scrollLeft + offsetLeft
|
|
48
|
+
: rect.right - containerRect.left + tableContainer.scrollLeft - offsetRight;
|
|
49
|
+
const top = rect.top - containerRect.top + tableContainer.scrollTop;
|
|
50
|
+
setPosition({ top, left, container: tableContainer });
|
|
51
|
+
} else {
|
|
52
|
+
const left =
|
|
53
|
+
dropIndicatorPosition === "before" ? rect.left + offsetLeft : rect.right - offsetRight;
|
|
54
|
+
const top = rect.top;
|
|
55
|
+
setPosition({ top, left });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
calculatePosition();
|
|
60
|
+
|
|
61
|
+
const tableContainer = elementRef.current.closest(".purpur-table__container");
|
|
62
|
+
const handleUpdate = () => requestAnimationFrame(calculatePosition);
|
|
63
|
+
|
|
64
|
+
if (tableContainer) tableContainer.addEventListener("scroll", handleUpdate, { passive: true });
|
|
65
|
+
window.addEventListener("resize", handleUpdate, { passive: true });
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
if (tableContainer) tableContainer.removeEventListener("scroll", handleUpdate);
|
|
69
|
+
window.removeEventListener("resize", handleUpdate);
|
|
70
|
+
};
|
|
71
|
+
}, [dropIndicatorPosition, elementRef]);
|
|
72
|
+
|
|
73
|
+
return position;
|
|
74
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type UniqueIdentifier, useDndContext } from "@dnd-kit/core";
|
|
2
|
+
|
|
3
|
+
export type DropIndicatorPosition = "before" | "after" | null;
|
|
4
|
+
|
|
5
|
+
interface UseDropIndicatorProps {
|
|
6
|
+
itemId?: UniqueIdentifier;
|
|
7
|
+
sortableId?: string;
|
|
8
|
+
enableDrag: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hook that determines where to show a drop indicator when dragging items
|
|
13
|
+
* Works for both table columns and rows
|
|
14
|
+
*/
|
|
15
|
+
export function useDropIndicator({
|
|
16
|
+
itemId,
|
|
17
|
+
sortableId,
|
|
18
|
+
enableDrag,
|
|
19
|
+
}: UseDropIndicatorProps): DropIndicatorPosition {
|
|
20
|
+
const dndContext = useDndContext();
|
|
21
|
+
|
|
22
|
+
// Default: no indicator
|
|
23
|
+
let dropIndicatorPosition: DropIndicatorPosition = null;
|
|
24
|
+
if (enableDrag && dndContext.active && dndContext.over) {
|
|
25
|
+
const activeId = dndContext.active.id;
|
|
26
|
+
const overId = dndContext.over.id;
|
|
27
|
+
const currentId = sortableId || itemId;
|
|
28
|
+
|
|
29
|
+
if (activeId !== overId && overId === currentId) {
|
|
30
|
+
// Try to get the current order from sortable items first (reflects drag order)
|
|
31
|
+
// Fall back to droppableContainers if items haven't been populated yet
|
|
32
|
+
const sortableItems = dndContext.over.data.current?.sortable?.items;
|
|
33
|
+
const itemOrder =
|
|
34
|
+
sortableItems && sortableItems.length > 0
|
|
35
|
+
? sortableItems
|
|
36
|
+
: Array.from(dndContext.droppableContainers.keys());
|
|
37
|
+
|
|
38
|
+
const activeIndex = itemOrder.indexOf(activeId as string);
|
|
39
|
+
const overIndex = itemOrder.indexOf(overId as string);
|
|
40
|
+
|
|
41
|
+
dropIndicatorPosition = activeIndex > overIndex ? "before" : "after";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return dropIndicatorPosition;
|
|
46
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type RefObject,useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export function useElementVisibility<T extends HTMLElement = HTMLElement>(
|
|
4
|
+
ref: RefObject<T | null>,
|
|
5
|
+
threshold: number = 1
|
|
6
|
+
): boolean {
|
|
7
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const currentElement = ref.current;
|
|
11
|
+
if (!currentElement) return;
|
|
12
|
+
|
|
13
|
+
const observer = new window.IntersectionObserver(
|
|
14
|
+
([entry]) => {
|
|
15
|
+
setIsVisible(entry.isIntersecting);
|
|
16
|
+
},
|
|
17
|
+
{ threshold }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
observer.observe(currentElement);
|
|
21
|
+
|
|
22
|
+
return () => {
|
|
23
|
+
observer.unobserve(currentElement);
|
|
24
|
+
};
|
|
25
|
+
}, [ref, threshold]);
|
|
26
|
+
|
|
27
|
+
return isVisible;
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect,useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface TableScrollRef {
|
|
4
|
+
current: HTMLElement | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const useTableScroll = (ref: TableScrollRef): boolean => {
|
|
8
|
+
const [isScrolled, setIsScrolled] = useState<boolean>(false);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const handleScroll = (): void => {
|
|
12
|
+
if (ref.current) {
|
|
13
|
+
setIsScrolled(ref.current.scrollLeft > 0);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const container = ref.current;
|
|
18
|
+
if (container) {
|
|
19
|
+
container.addEventListener("scroll", handleScroll);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (): void => {
|
|
23
|
+
if (container) {
|
|
24
|
+
container.removeEventListener("scroll", handleScroll);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}, [ref]);
|
|
28
|
+
|
|
29
|
+
return isScrolled;
|
|
30
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closestCorners,
|
|
3
|
+
type DroppableContainer,
|
|
4
|
+
getFirstCollision,
|
|
5
|
+
KeyboardCode,
|
|
6
|
+
type KeyboardCoordinateGetter,
|
|
7
|
+
} from "@dnd-kit/core";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Enhanced keyboard coordinate handler that better handles columns of different widths
|
|
11
|
+
* by positioning the dragged column appropriately within target columns
|
|
12
|
+
*/
|
|
13
|
+
export const enhancedColumnKeyboardCoordinates: KeyboardCoordinateGetter = (
|
|
14
|
+
event,
|
|
15
|
+
{ context: { active, collisionRect, droppableRects, droppableContainers, over } }
|
|
16
|
+
) => {
|
|
17
|
+
if (!active || !collisionRect) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Only handle left/right movements for column dragging
|
|
22
|
+
if (event.code === KeyboardCode.Right || event.code === KeyboardCode.Left) {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
|
|
25
|
+
const filteredContainers: DroppableContainer[] = [];
|
|
26
|
+
|
|
27
|
+
droppableContainers.getEnabled().forEach((container) => {
|
|
28
|
+
if (container?.disabled) return;
|
|
29
|
+
|
|
30
|
+
const rect = droppableRects.get(container.id);
|
|
31
|
+
if (!rect) return;
|
|
32
|
+
|
|
33
|
+
// Filter based on direction
|
|
34
|
+
if (event.code === KeyboardCode.Right && collisionRect.left < rect.left) {
|
|
35
|
+
filteredContainers.push(container);
|
|
36
|
+
} else if (event.code === KeyboardCode.Left && collisionRect.left > rect.left) {
|
|
37
|
+
filteredContainers.push(container);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Sort containers by distance (nearest first) for left movement
|
|
42
|
+
if (event.code === KeyboardCode.Left && filteredContainers.length > 0) {
|
|
43
|
+
filteredContainers.sort((a, b) => {
|
|
44
|
+
const rectA = droppableRects.get(a.id);
|
|
45
|
+
const rectB = droppableRects.get(b.id);
|
|
46
|
+
if (!rectA || !rectB) return 0;
|
|
47
|
+
|
|
48
|
+
// Sort by closest to furthest from the current position (for left movement)
|
|
49
|
+
return rectB.left - rectA.left;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Find closest target
|
|
54
|
+
const collisions = closestCorners({
|
|
55
|
+
active,
|
|
56
|
+
collisionRect,
|
|
57
|
+
droppableRects,
|
|
58
|
+
droppableContainers: filteredContainers,
|
|
59
|
+
pointerCoordinates: null,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let targetId = getFirstCollision(collisions, "id");
|
|
63
|
+
|
|
64
|
+
// Get second collision if first is current "over" element
|
|
65
|
+
if (targetId === over?.id && collisions.length > 1) {
|
|
66
|
+
targetId = collisions[1].id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (targetId) {
|
|
70
|
+
const targetRect = droppableRects.get(targetId);
|
|
71
|
+
|
|
72
|
+
if (targetRect) {
|
|
73
|
+
return {
|
|
74
|
+
x: targetRect.left + targetRect.width * 0.35,
|
|
75
|
+
y: targetRect.top + targetRect.height / 2,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// For other keys, we don't handle them
|
|
82
|
+
return;
|
|
83
|
+
};
|