@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.
Files changed (82) hide show
  1. package/dist/LICENSE.txt +205 -35
  2. package/dist/drag-indicator-circle.d.ts +13 -0
  3. package/dist/drag-indicator-circle.d.ts.map +1 -0
  4. package/dist/draggable-table.d.ts +23 -0
  5. package/dist/draggable-table.d.ts.map +1 -0
  6. package/dist/empty-table.d.ts +14 -0
  7. package/dist/empty-table.d.ts.map +1 -0
  8. package/dist/loading-table-rows.d.ts +13 -0
  9. package/dist/loading-table-rows.d.ts.map +1 -0
  10. package/dist/styles.css +1 -1
  11. package/dist/table-body.d.ts +2 -2
  12. package/dist/table-body.d.ts.map +1 -1
  13. package/dist/table-column-header-cell.d.ts +15 -2
  14. package/dist/table-column-header-cell.d.ts.map +1 -1
  15. package/dist/table-content.d.ts +42 -0
  16. package/dist/table-content.d.ts.map +1 -0
  17. package/dist/table-headers.d.ts +28 -0
  18. package/dist/table-headers.d.ts.map +1 -0
  19. package/dist/table-row-cell-skeleton.d.ts +1 -1
  20. package/dist/table-row-cell-skeleton.d.ts.map +1 -1
  21. package/dist/table-row-cell.d.ts +5 -2
  22. package/dist/table-row-cell.d.ts.map +1 -1
  23. package/dist/table-row.d.ts +2 -2
  24. package/dist/table-row.d.ts.map +1 -1
  25. package/dist/table-settings-drawer.d.ts +44 -11
  26. package/dist/table-settings-drawer.d.ts.map +1 -1
  27. package/dist/table.cjs.js +89 -85
  28. package/dist/table.cjs.js.map +1 -1
  29. package/dist/table.d.ts +3 -3
  30. package/dist/table.d.ts.map +1 -1
  31. package/dist/table.es.js +14040 -9810
  32. package/dist/table.es.js.map +1 -1
  33. package/dist/test-utils/helpers.d.ts +1 -0
  34. package/dist/test-utils/helpers.d.ts.map +1 -1
  35. package/dist/types.d.ts +23 -2
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/use-drag-handle.hook.d.ts +15 -0
  38. package/dist/use-drag-handle.hook.d.ts.map +1 -0
  39. package/dist/use-drag-indicator-position.hook.d.ts +19 -0
  40. package/dist/use-drag-indicator-position.hook.d.ts.map +1 -0
  41. package/dist/use-drop-indicator.hook.d.ts +15 -0
  42. package/dist/use-drop-indicator.hook.d.ts.map +1 -0
  43. package/dist/use-element-visibility.hook.d.ts +4 -0
  44. package/dist/use-element-visibility.hook.d.ts.map +1 -0
  45. package/dist/use-table-scroll.hook.d.ts +6 -0
  46. package/dist/use-table-scroll.hook.d.ts.map +1 -0
  47. package/dist/utils/custom-keyboard-coordinates.d.ts +8 -0
  48. package/dist/utils/custom-keyboard-coordinates.d.ts.map +1 -0
  49. package/package.json +27 -23
  50. package/src/drag-indicator-circle.tsx +36 -0
  51. package/src/draggable-table.test.tsx +381 -0
  52. package/src/draggable-table.tsx +191 -0
  53. package/src/empty-table.tsx +54 -0
  54. package/src/loading-table-rows.tsx +41 -0
  55. package/src/table-body.tsx +1 -3
  56. package/src/table-column-header-cell.tsx +135 -64
  57. package/src/table-content-drag.test.tsx +505 -0
  58. package/src/table-content.tsx +165 -0
  59. package/src/table-dnd-integration.test.tsx +425 -0
  60. package/src/table-drag-and-drop.test.tsx +276 -0
  61. package/src/table-headers.tsx +118 -0
  62. package/src/table-row-cell-skeleton.tsx +1 -1
  63. package/src/table-row-cell.test.tsx +2 -1
  64. package/src/table-row-cell.tsx +42 -31
  65. package/src/table-row.tsx +1 -3
  66. package/src/table-settings-drawer.module.scss +165 -2
  67. package/src/table-settings-drawer.test.tsx +0 -99
  68. package/src/table-settings-drawer.tsx +359 -53
  69. package/src/table.module.scss +191 -30
  70. package/src/table.stories.tsx +60 -4
  71. package/src/table.test.tsx +5 -1
  72. package/src/table.tsx +255 -213
  73. package/src/test-utils/helpers.ts +2 -0
  74. package/src/types.ts +25 -2
  75. package/src/use-drag-handle.hook.tsx +60 -0
  76. package/src/use-drag-handle.test.tsx +380 -0
  77. package/src/use-drag-indicator-position.hook.ts +74 -0
  78. package/src/use-drop-indicator.hook.ts +46 -0
  79. package/src/use-element-visibility.hook.ts +28 -0
  80. package/src/use-table-scroll.hook.tsx +30 -0
  81. package/src/utils/custom-keyboard-coordinates.ts +83 -0
  82. 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
+ };