diff --git a/multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip b/multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip new file mode 100644 index 0000000..2ecebfa Binary files /dev/null and b/multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip differ diff --git a/src/components/DismissibleAlert.tsx b/src/components/DismissibleAlert.tsx new file mode 100644 index 0000000..acecccf --- /dev/null +++ b/src/components/DismissibleAlert.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { X } from "lucide-react"; + +type AlertTone = "success" | "info" | "warning" | "danger"; + +type DismissibleAlertProps = { + tone?: AlertTone; + children: ReactNode; + dismissible?: boolean; + className?: string; + compact?: boolean; + resetKey?: string | number; +}; + +export default function DismissibleAlert({ + tone = "info", + children, + dismissible = true, + className = "", + compact = false, + resetKey +}: DismissibleAlertProps) { + const [visible, setVisible] = useState(true); + + useEffect(() => { + setVisible(true); + }, [resetKey, children]); + + if (!visible) return null; + + return ( +
+
{children}
+ {dismissible && ( + + )} +
+ ); +} diff --git a/src/components/table/DataGrid.tsx b/src/components/table/DataGrid.tsx new file mode 100644 index 0000000..f05f8e9 --- /dev/null +++ b/src/components/table/DataGrid.tsx @@ -0,0 +1,672 @@ +import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, X } from "lucide-react"; + +export type DataGridSortDirection = "asc" | "desc"; +export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date"; + +type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after"; + +export type DataGridColumn = { + id: string; + header: ReactNode; + width?: number | string; + minWidth?: number; + maxWidth?: number; + resizable?: boolean; + sortable?: boolean; + filterable?: boolean; + filterType?: DataGridFilterType; + sticky?: "start" | "end"; + align?: "left" | "center" | "right"; + className?: string; + headerClassName?: string; + render?: (row: T, index: number) => ReactNode; + value?: (row: T, index: number) => unknown; + sortValue?: (row: T, index: number) => unknown; + filterValue?: (row: T, index: number) => unknown; +}; + +type DataGridState = { + sort?: { columnId: string; direction: DataGridSortDirection }; + filters?: Record; + widths?: Record; +}; + +type DataGridProps = { + id: string; + rows: T[]; + columns: DataGridColumn[]; + getRowKey: (row: T, index: number) => string; + emptyText?: ReactNode; + fit?: "content" | "container"; + className?: string; + rowClassName?: (row: T, index: number) => string | undefined; + storageKey?: string; +}; + +type FilterPosition = { + top: number; + left: number; + width: number; +}; + +const STORAGE_PREFIX = "multimailer.datagrid."; +const FILTER_POPOVER_WIDTH = 320; +const FILTER_POPOVER_MARGIN = 12; + +export default function DataGrid({ + id, + rows, + columns, + getRowKey, + emptyText = "No rows found.", + fit = "content", + className = "", + rowClassName, + storageKey +}: DataGridProps) { + const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`; + const [state, setState] = useState(() => loadState(localStorageKey)); + const [resizeState, setResizeState] = useState<{ columnId: string; startX: number; startWidth: number } | null>(null); + const [openFilterColumnId, setOpenFilterColumnId] = useState(null); + const [filterPosition, setFilterPosition] = useState(null); + const gridRef = useRef(null); + const headerCellRefs = useRef>({}); + const filterButtonRefs = useRef>({}); + const filterPopoverRef = useRef(null); + const [measuredWidths, setMeasuredWidths] = useState>({}); + + useEffect(() => { + try { + window.localStorage.setItem(localStorageKey, JSON.stringify(state)); + } catch { + // Local storage is an enhancement only. + } + }, [localStorageKey, state]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const filtersFromUrl: Record = {}; + for (const column of columns) { + const exact = params.get(`grid.${id}.${column.id}`) ?? params.get(`filter.${id}.${column.id}`); + if (exact) filtersFromUrl[column.id] = exact; + } + const table = params.get("table"); + const filter = params.get("filter"); + if (table === id && filter?.includes(":")) { + const [columnId, ...parts] = filter.split(":"); + const value = parts.join(":"); + if (columnId && value) filtersFromUrl[columnId] = value; + } + if (Object.keys(filtersFromUrl).length > 0) { + setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), ...filtersFromUrl } })); + } + }, [id, columns]); + + useLayoutEffect(() => { + function measure() { + const next: Record = {}; + for (const column of columns) { + const element = headerCellRefs.current[column.id]; + if (!element) continue; + const width = Math.round(element.getBoundingClientRect().width); + if (width > 0) next[column.id] = width; + } + setMeasuredWidths((current) => shallowEqualNumberRecords(current, next) ? current : next); + } + + measure(); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + } + + const observer = new ResizeObserver(measure); + for (const column of columns) { + const element = headerCellRefs.current[column.id]; + if (element) observer.observe(element); + } + return () => observer.disconnect(); + }, [columns, state.widths]); + + useEffect(() => { + if (!resizeState) return; + const activeResize = resizeState; + function onMove(event: MouseEvent) { + const column = columns.find((item) => item.id === activeResize.columnId); + const minWidth = column?.minWidth ?? 80; + const maxWidth = column?.maxWidth ?? 2000; + const nextWidth = Math.min(maxWidth, Math.max(minWidth, activeResize.startWidth + event.clientX - activeResize.startX)); + setState((current) => ({ ...current, widths: { ...(current.widths ?? {}), [activeResize.columnId]: nextWidth } })); + } + function onUp() { + setResizeState(null); + } + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [resizeState, columns]); + + useEffect(() => { + if (!openFilterColumnId) return undefined; + const activeFilterColumnId = openFilterColumnId; + function update() { + updateFilterPosition(activeFilterColumnId); + } + update(); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + return () => { + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + }; + }, [openFilterColumnId]); + + useEffect(() => { + if (!openFilterColumnId) return undefined; + const activeFilterColumnId = openFilterColumnId; + function onPointerDown(event: MouseEvent) { + const target = event.target as Node | null; + const popover = filterPopoverRef.current; + const trigger = filterButtonRefs.current[activeFilterColumnId]; + if (target && (popover?.contains(target) || trigger?.contains(target))) return; + setOpenFilterColumnId(null); + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpenFilterColumnId(null); + } + window.addEventListener("mousedown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("mousedown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [openFilterColumnId]); + + const filterTypes = useMemo(() => { + const result: Record = {}; + for (const column of columns) result[column.id] = column.filterType ?? inferFilterType(column, rows); + return result; + }, [columns, rows]); + + const visibleRows = useMemo(() => { + const filters = state.filters ?? {}; + const filtered = rows.filter((row, rowIndex) => columns.every((column) => { + const filterValue = filters[column.id] ?? ""; + if (!filterValue.trim()) return true; + const rawValue = valueForFilter(column, row, rowIndex); + return matchesFilter(rawValue, filterValue, filterTypes[column.id]); + })); + if (!state.sort) return filtered; + const sortColumn = columns.find((column) => column.id === state.sort?.columnId); + if (!sortColumn) return filtered; + return [...filtered].sort((a, b) => { + const aIndex = rows.indexOf(a); + const bIndex = rows.indexOf(b); + const aValue = sortColumn.sortValue?.(a, aIndex) ?? sortColumn.value?.(a, aIndex) ?? ""; + const bValue = sortColumn.sortValue?.(b, bIndex) ?? sortColumn.value?.(b, bIndex) ?? ""; + const result = compareValues(aValue, bValue); + return state.sort?.direction === "desc" ? -result : result; + }); + }, [rows, columns, state.filters, state.sort, filterTypes]); + + const stretchedColumnIds = useMemo(() => chooseStretchedColumns(columns, state.widths), [columns, state.widths]); + const templateColumns = columns.map((column) => widthForColumn(column, state.widths?.[column.id], stretchedColumnIds.has(column.id))).join(" "); + const hasFlexibleColumns = columns.some((column) => stretchedColumnIds.has(column.id) || isFlexibleColumn(column, state.widths?.[column.id])); + const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]); + const gridClassName = `data-grid ${hasFlexibleColumns ? "data-grid-has-flex" : "data-grid-fixed-only"}`; + const activeFilterColumn = openFilterColumnId ? columns.find((column) => column.id === openFilterColumnId) : undefined; + + function toggleSort(column: DataGridColumn) { + if (!column.sortable) return; + setState((current) => { + if (current.sort?.columnId !== column.id) return { ...current, sort: { columnId: column.id, direction: "asc" } }; + if (current.sort.direction === "asc") return { ...current, sort: { columnId: column.id, direction: "desc" } }; + return { ...current, sort: undefined }; + }); + } + + function patchFilter(columnId: string, value: string) { + setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), [columnId]: value } })); + } + + function clearFilter(columnId: string) { + setState((current) => { + const nextFilters = { ...(current.filters ?? {}) }; + delete nextFilters[columnId]; + return { ...current, filters: nextFilters }; + }); + } + + function updateFilterPosition(columnId: string) { + const element = filterButtonRefs.current[columnId]; + if (!element) return; + const rect = element.getBoundingClientRect(); + const width = Math.min(FILTER_POPOVER_WIDTH, Math.max(240, window.innerWidth - FILTER_POPOVER_MARGIN * 2)); + const left = Math.min(Math.max(FILTER_POPOVER_MARGIN, rect.left), window.innerWidth - width - FILTER_POPOVER_MARGIN); + const top = Math.min(rect.bottom + 8, window.innerHeight - 120); + setFilterPosition({ top, left, width }); + } + + function toggleFilterPopover(columnId: string) { + setOpenFilterColumnId((current) => { + if (current === columnId) return null; + window.requestAnimationFrame(() => updateFilterPosition(columnId)); + return columnId; + }); + } + + return ( +
+
+ {columns.map((column, columnIndex) => { + const sorted = state.sort?.columnId === column.id ? state.sort.direction : undefined; + const hasFilter = Boolean((state.filters?.[column.id] ?? "").trim()); + return ( +
{ headerCellRefs.current[column.id] = element; }} + className={`data-grid-cell data-grid-header-cell ${column.headerClassName ?? ""} ${column.sortable ? "is-sortable" : ""} ${sorted ? "is-sorted" : ""} ${stickyClass(column)}`.trim()} + style={stickyStyle(column, stickyOffsets[columnIndex])} + > + + {column.filterable && ( + + )} + {column.resizable && ( + + )} +
+ ); + })} + {visibleRows.length === 0 ? ( +
+
{emptyText}
+
+ ) : visibleRows.map((row, visibleIndex) => { + const originalIndex = rows.indexOf(row); + const rowClass = rowClassName?.(row, originalIndex); + const parityClass = visibleIndex % 2 === 0 ? "data-grid-row-even" : "data-grid-row-odd"; + return columns.map((column, columnIndex) => ( +
+ {column.render ? column.render(row, originalIndex) : stringifyCell(column.value?.(row, originalIndex))} +
+ )); + })} +
+ {activeFilterColumn && filterPosition && createPortal( + patchFilter(activeFilterColumn.id, value)} + onClear={() => clearFilter(activeFilterColumn.id)} + onClose={() => setOpenFilterColumnId(null)} + />, + document.body + )} +
+ ); +} + +type FilterPopoverProps = { + column: Pick, "id" | "header">; + filterType: DataGridFilterType; + value: string; + position: FilterPosition; + onChange: (value: string) => void; + onClear: () => void; + onClose: () => void; +}; + +const FilterPopover = forwardRef(function FilterPopover( + { column, filterType, value, position, onChange, onClear, onClose }, + ref +) { + const parsed = parseTypedFilter(value, filterType); + const operatorOptions = filterType === "date" ? DATE_OPERATORS : NUMBER_OPERATORS; + return ( +
+
+ Filter {column.header} + +
+ {filterType === "boolean" ? ( + + ) : filterType === "number" || filterType === "integer" || filterType === "date" ? ( +
+ + +
+ ) : ( + + )} +
+ ); +}); + +function SortIcon({ direction }: { direction?: DataGridSortDirection }) { + if (direction === "asc") return ; + if (direction === "desc") return ; + return