@mendylanda/ui 0.2.0 → 0.3.0-alpha.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.
@@ -0,0 +1,53 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ export function useResultSelection({ scope, rowIds, totalCount, }) {
4
+ const [stored, setStored] = useState({
5
+ key: scope.key,
6
+ value: { mode: "ids", ids: [] },
7
+ });
8
+ if (stored.key !== scope.key) {
9
+ setStored({ key: scope.key, value: { mode: "ids", ids: [] } });
10
+ }
11
+ const selection = stored.key === scope.key ? stored.value : { mode: "ids", ids: [] };
12
+ const ids = new Set(selection.mode === "ids" ? selection.ids : selection.excludedIds);
13
+ const rowSelection = Object.fromEntries(rowIds
14
+ .filter((id) => (selection.mode === "ids" ? ids.has(id) : !ids.has(id)))
15
+ .map((id) => [id, true]));
16
+ function onRowSelectionChange(updater) {
17
+ setStored((previous) => {
18
+ const value = previous.key === scope.key ? previous.value : { mode: "ids", ids: [] };
19
+ const selected = new Set(value.mode === "ids" ? value.ids : value.excludedIds);
20
+ const current = Object.fromEntries(rowIds
21
+ .filter((id) => (value.mode === "ids" ? selected.has(id) : !selected.has(id)))
22
+ .map((id) => [id, true]));
23
+ const next = typeof updater === "function" ? updater(current) : updater;
24
+ for (const id of rowIds) {
25
+ if (value.mode === "ids" ? next[id] : !next[id])
26
+ selected.add(id);
27
+ else
28
+ selected.delete(id);
29
+ }
30
+ return {
31
+ key: scope.key,
32
+ value: value.mode === "ids"
33
+ ? { mode: "ids", ids: [...selected] }
34
+ : { ...value, excludedIds: [...selected] },
35
+ };
36
+ });
37
+ }
38
+ return {
39
+ selection,
40
+ rowSelection,
41
+ onRowSelectionChange,
42
+ count: selection.mode === "ids"
43
+ ? selection.ids.length
44
+ : totalCount == null
45
+ ? undefined
46
+ : Math.max(0, totalCount - selection.excludedIds.length),
47
+ clear: () => setStored({ key: scope.key, value: { mode: "ids", ids: [] } }),
48
+ selectAllMatching: () => setStored({
49
+ key: scope.key,
50
+ value: { mode: "matching", scope: scope.value, excludedIds: [] },
51
+ }),
52
+ };
53
+ }
@@ -0,0 +1,15 @@
1
+ import type { KeyboardEvent, RefObject } from "react";
2
+ import type { DataTableInstance } from "./use-data-table.js";
3
+ export declare function useTableInteraction<T extends object>({ table, pinningActive, container, queryKey, onRowActivate, onCopyError, scrollToIndex, }: {
4
+ table: DataTableInstance<T>;
5
+ pinningActive: boolean;
6
+ container: RefObject<HTMLDivElement | null>;
7
+ queryKey?: string;
8
+ onRowActivate?: (row: T) => void;
9
+ onCopyError?: (error: unknown) => void;
10
+ scrollToIndex: (index: number) => void;
11
+ }): {
12
+ announcement: string;
13
+ copied: boolean;
14
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
15
+ };
@@ -0,0 +1,125 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { interactiveSelector, selectedCellsText } from "./clipboard.js";
4
+ export function useTableInteraction({ table, pinningActive, container, queryKey, onRowActivate, onCopyError, scrollToIndex, }) {
5
+ const rows = table.getRowModel().rows;
6
+ const columns = [
7
+ ...table.getStartVisibleLeafColumns(),
8
+ ...table.getCenterVisibleLeafColumns(),
9
+ ...table.getEndVisibleLeafColumns(),
10
+ ];
11
+ const focused = table.getFocusedCell();
12
+ const [announcement, setAnnouncement] = useState("");
13
+ const [copied, setCopied] = useState(false);
14
+ const copyTimer = useRef(undefined);
15
+ const mounted = useRef(true);
16
+ useEffect(() => {
17
+ mounted.current = true;
18
+ return () => {
19
+ mounted.current = false;
20
+ clearTimeout(copyTimer.current);
21
+ };
22
+ }, []);
23
+ const resetCellSelection = table.resetCellSelection;
24
+ useEffect(() => {
25
+ resetCellSelection(true);
26
+ if (container.current)
27
+ container.current.scrollTop = 0;
28
+ }, [queryKey, resetCellSelection, container]);
29
+ useEffect(() => {
30
+ const dismiss = (event) => {
31
+ if (container.current && !container.current.contains(event.target))
32
+ resetCellSelection(true);
33
+ };
34
+ document.addEventListener("pointerdown", dismiss);
35
+ return () => document.removeEventListener("pointerdown", dismiss);
36
+ }, [resetCellSelection, container]);
37
+ async function copySelection() {
38
+ try {
39
+ const text = selectedCellsText(table);
40
+ if (!text)
41
+ return;
42
+ await navigator.clipboard.writeText(text);
43
+ if (!mounted.current)
44
+ return;
45
+ setAnnouncement("Selected cells copied");
46
+ setCopied(true);
47
+ clearTimeout(copyTimer.current);
48
+ copyTimer.current = setTimeout(() => setCopied(false), 300);
49
+ }
50
+ catch (reason) {
51
+ if (mounted.current) {
52
+ setAnnouncement("Could not copy selected cells");
53
+ onCopyError?.(reason);
54
+ }
55
+ }
56
+ }
57
+ function focusCell(rowId, columnId) {
58
+ const index = rows.findIndex((row) => row.id === rowId);
59
+ if (index < 0)
60
+ return;
61
+ scrollToIndex(index);
62
+ requestAnimationFrame(() => {
63
+ const cell = container.current?.querySelector(`[data-row-id="${CSS.escape(rowId)}"][data-column-id="${CSS.escape(columnId)}"]`);
64
+ cell?.focus({ preventScroll: true });
65
+ if (cell && (!pinningActive || !table.getColumn(columnId)?.getIsPinned())) {
66
+ const box = cell.getBoundingClientRect(), viewport = container.current.getBoundingClientRect();
67
+ const startWidth = !pinningActive
68
+ ? 0
69
+ : table.getStartVisibleLeafColumns().reduce((sum, col) => sum + col.getSize(), 0);
70
+ const endWidth = !pinningActive
71
+ ? 0
72
+ : table.getEndVisibleLeafColumns().reduce((sum, col) => sum + col.getSize(), 0);
73
+ if (box.left < viewport.left + startWidth)
74
+ container.current.scrollLeft += box.left - viewport.left - startWidth;
75
+ else if (box.right > viewport.right - endWidth)
76
+ container.current.scrollLeft += box.right - viewport.right + endWidth;
77
+ }
78
+ });
79
+ }
80
+ const onKeyDown = (event) => {
81
+ if (event.target.closest(interactiveSelector))
82
+ return;
83
+ if ((event.ctrlKey || event.metaKey) &&
84
+ event.key.toLowerCase() === "c" &&
85
+ !window.getSelection()?.toString() &&
86
+ table.getSelectedCellCount()) {
87
+ event.preventDefault();
88
+ void copySelection();
89
+ return;
90
+ }
91
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
92
+ event.preventDefault();
93
+ table.selectAllCells();
94
+ return;
95
+ }
96
+ if (event.key === "Escape") {
97
+ table.resetCellSelection(true);
98
+ return;
99
+ }
100
+ const directions = {
101
+ ArrowUp: "up",
102
+ ArrowDown: "down",
103
+ ArrowLeft: "left",
104
+ ArrowRight: "right",
105
+ };
106
+ const direction = directions[event.key];
107
+ if (direction) {
108
+ event.preventDefault();
109
+ if (!table.getFocusedCell() && rows[0] && columns[0])
110
+ table.setFocusedCell(rows[0].id, columns[0].id);
111
+ else if (event.shiftKey)
112
+ table.extendCellSelection(direction);
113
+ else
114
+ table.moveCellSelection(direction);
115
+ const range = table.atoms.cellSelection.get().at(-1);
116
+ if (range)
117
+ focusCell(range.focusRowId, range.focusColumnId);
118
+ }
119
+ else if (event.key === "Enter" && focused && onRowActivate) {
120
+ event.preventDefault();
121
+ onRowActivate(focused.row.original);
122
+ }
123
+ };
124
+ return { announcement, copied, onKeyDown };
125
+ }
@@ -0,0 +1,3 @@
1
+ import type { RefObject } from "react";
2
+ /** Vertical virtualization does not notify React when only the viewport width changes. */
3
+ export declare function useViewportWidth(container: RefObject<HTMLElement | null>): number | null;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { useLayoutEffect, useState } from "react";
3
+ /** Vertical virtualization does not notify React when only the viewport width changes. */
4
+ export function useViewportWidth(container) {
5
+ const [width, setWidth] = useState(null);
6
+ useLayoutEffect(() => {
7
+ const element = container.current;
8
+ if (!element)
9
+ return;
10
+ const measure = () => setWidth(element.clientWidth);
11
+ measure();
12
+ const observer = new ResizeObserver(measure);
13
+ observer.observe(element);
14
+ return () => observer.disconnect();
15
+ }, [container]);
16
+ return width;
17
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mendylanda/ui",
3
- "version": "0.2.0",
4
- "description": "Mendy Landa’s reusable React components. Typed filters with dynamic options, URL persistence, and customizable UI.",
3
+ "version": "0.3.0-alpha.0",
4
+ "description": "Mendy Landa\u2019s reusable React components. Typed filters and tables with shared interaction behavior and customizable UI.",
5
5
  "homepage": "https://ui.mendylanda.com",
6
6
  "license": "MIT",
7
7
  "author": "Mendy Landa",
@@ -61,16 +61,27 @@
61
61
  "./styles.tailwind3.css": {
62
62
  "types": "./dist/styles.css.d.ts",
63
63
  "default": "./dist/styles.tailwind3.css"
64
+ },
65
+ "./table": {
66
+ "types": "./dist/table/index.d.ts",
67
+ "import": "./dist/table/index.js",
68
+ "default": "./dist/table/index.js"
64
69
  }
65
70
  },
66
71
  "publishConfig": {
67
72
  "access": "public"
68
73
  },
74
+ "scripts": {
75
+ "build": "tsc -p tsconfig.build.json && node scripts/build-css.mjs",
76
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
77
+ "prepack": "pnpm build"
78
+ },
69
79
  "dependencies": {
70
80
  "@radix-ui/react-checkbox": "^1.3.3",
71
81
  "@radix-ui/react-dropdown-menu": "^2.1.16",
72
82
  "@radix-ui/react-label": "^2.1.7",
73
83
  "@radix-ui/react-slot": "^1.2.3",
84
+ "@tanstack/react-table": "9.2.4",
74
85
  "@tanstack/react-virtual": "^3.14.11",
75
86
  "class-variance-authority": "^0.7.1",
76
87
  "clsx": "^2.1.1",
@@ -99,9 +110,5 @@
99
110
  "nuqs": {
100
111
  "optional": true
101
112
  }
102
- },
103
- "scripts": {
104
- "build": "tsc -p tsconfig.build.json && node scripts/build-css.mjs",
105
- "typecheck": "tsc -p tsconfig.build.json --noEmit"
106
113
  }
107
- }
114
+ }