diff --git a/openframe-frontend-core/src/components/layout/list-page-layout.tsx b/openframe-frontend-core/src/components/layout/list-page-layout.tsx index 17deff70f..2d0651f26 100644 --- a/openframe-frontend-core/src/components/layout/list-page-layout.tsx +++ b/openframe-frontend-core/src/components/layout/list-page-layout.tsx @@ -6,10 +6,10 @@ import { useDebounce } from '../../hooks/ui/use-debounce'; import { cn } from '../../utils/cn'; import { Filter02Icon, SearchIcon } from '../icons-v2-generated'; import { Button } from '../ui/button'; +import type { TableFilters } from '../ui/data-table/types'; import { PageError } from '../ui/error-state'; import { FilterModal, type FilterGroup, type SortConfig, type SortDirection } from '../ui/filter-modal'; import { Input } from '../ui/input'; -import type { TableFilters } from '../ui/table/types'; import { ListPageContainer, type PageActionButton } from './page-container'; export interface ListPageLayoutProps { diff --git a/openframe-frontend-core/src/components/ui/table/.use-table-motion.md b/openframe-frontend-core/src/components/ui/data-table/.use-table-motion.md similarity index 100% rename from openframe-frontend-core/src/components/ui/table/.use-table-motion.md rename to openframe-frontend-core/src/components/ui/data-table/.use-table-motion.md diff --git a/openframe-frontend-core/src/components/ui/data-table/data-table-body.tsx b/openframe-frontend-core/src/components/ui/data-table/data-table-body.tsx index ba3453392..c8eb6ea72 100644 --- a/openframe-frontend-core/src/components/ui/data-table/data-table-body.tsx +++ b/openframe-frontend-core/src/components/ui/data-table/data-table-body.tsx @@ -7,6 +7,7 @@ import { useDataTableContext } from './data-table'; import { DataTableEmpty } from './data-table-empty'; import { DataTableRow } from './data-table-row'; import { DataTableSkeleton, PlaceholderRows, ReservedEmptyState } from './data-table-skeleton'; +import { useTableMotion } from './use-table-motion'; export interface DataTableBodyProps { /** Show skeleton rows while `loading` is true and data is empty. */ @@ -68,6 +69,25 @@ export interface DataTableBodyProps { * Interactive content inside must carry `data-no-row-click`. */ renderSubRow?: (item: T) => ReactNode; + /** + * Opt-in: a data reorder (same row id, new order) slides rows into place via + * FLIP instead of jumping. Rows render as framer-motion `motion.div`s with + * `layout="position"`, and framer-motion is loaded lazily — its own chunk — + * ONLY when this is set, so every other table stays motion-free. Off by + * default; the first paint after enabling is non-animated and the FLIP kicks + * in once the chunk has loaded. + * + * Two things stay with the consumer: + * - pass `getRowId` to `useDataTable`. TanStack's default row id is the row + * INDEX, and an index does not move when the data does, so React would + * re-render cells in place and nothing would animate; + * - honour `prefers-reduced-motion` at the call site (pass `false` when + * reduced motion is requested) so this prop stays purely mechanical. + * + * Rows that are a whole-card `` (`rowHref` without `onRowClick`, no + * sub-row) are not animated. + */ + animateRowReorder?: boolean; } /** @@ -90,9 +110,13 @@ export function DataTableBody({ rowHref, minRows, renderSubRow, + animateRowReorder, }: DataTableBodyProps) { const table = useDataTableContext(); const rows = table.getRowModel().rows; + // Above the early returns — hooks run unconditionally. Resolves to `null` + // (and fetches nothing) unless `animateRowReorder` is set. + const tableMotion = useTableMotion(Boolean(animateRowReorder)); if (loading && rows.length === 0) { return ( @@ -132,26 +156,37 @@ export function DataTableBody({ const padCount = minRows ? Math.max(0, minRows - rows.length) : 0; + const rowNodes = rows.map((row, index) => { + const item = row.original; + const href = rowHref?.(item) ?? undefined; + const cls = typeof rowClassName === 'function' ? rowClassName(item, index) : rowClassName; + return ( + + key={row.id} + row={row} + onClick={onRowClick} + href={href} + compact={compact} + autoHeight={autoHeight} + rowHeightClassName={rowHeightClassName} + className={cls} + subRow={renderSubRow?.(item)} + animateRowReorder={animateRowReorder} + motionDiv={tableMotion?.motionDiv} + /> + ); + }); + // With `animateRowReorder` on, ONLY the real rows go inside a `LayoutGroup` + // so a reorder (same id, new order) animates via FLIP — the invisible pad + // rows below are deliberately left outside it. Until framer-motion has + // lazily resolved (and whenever the prop is off) the rows render as plain + // `
`s. No `AnimatePresence`: the row set is stable across a reorder + // (no enter/exit); add it if a future task animates rows being added/removed. + const LayoutGroup = tableMotion?.LayoutGroup; + return (
- {rows.map((row, index) => { - const item = row.original; - const href = rowHref?.(item) ?? undefined; - const cls = typeof rowClassName === 'function' ? rowClassName(item, index) : rowClassName; - return ( - - key={row.id} - row={row} - onClick={onRowClick} - href={href} - compact={compact} - autoHeight={autoHeight} - rowHeightClassName={rowHeightClassName} - className={cls} - subRow={renderSubRow?.(item)} - /> - ); - })} + {animateRowReorder && LayoutGroup ? {rowNodes} : rowNodes} {padCount > 0 && }
); diff --git a/openframe-frontend-core/src/components/ui/data-table/data-table-row.tsx b/openframe-frontend-core/src/components/ui/data-table/data-table-row.tsx index 509bbcf40..fdd95e866 100644 --- a/openframe-frontend-core/src/components/ui/data-table/data-table-row.tsx +++ b/openframe-frontend-core/src/components/ui/data-table/data-table-row.tsx @@ -2,7 +2,7 @@ import { flexRender, type Row } from '@tanstack/react-table'; import type React from 'react'; -import { memo, useCallback, useRef, type ReactNode } from 'react'; +import { memo, useCallback, useRef, type ElementType, type ReactNode } from 'react'; import Link from '../../../embed-shims/next-link'; import { cn } from '../../../utils/cn'; import { ROW_HEIGHT_DESKTOP, ROW_SHELL_CLASSES } from './data-table-skeleton'; @@ -35,6 +35,15 @@ export interface DataTableRowProps { className?: string; /** Expandable content rendered below the cells, inside the same card. */ subRow?: ReactNode; + /** Opt-in FLIP reorder — see `DataTableBodyProps.animateRowReorder`. Forwarded by `DataTable.Body`. */ + animateRowReorder?: boolean; + /** + * Internal: the lazily-resolved framer-motion `motion.div`, injected by + * `DataTable.Body` so framer-motion stays out of the default bundle. Typed as + * a bare `ElementType` so this module never statically references + * framer-motion. Plain `
` is used until it's set. + */ + motionDiv?: ElementType; } /** @@ -83,6 +92,8 @@ function DataTableRowImpl({ rowHeightClassName, className, subRow, + animateRowReorder, + motionDiv, }: DataTableRowProps) { const hasSubRow = subRow != null && subRow !== false; // A sub-row carries its own interactive controls, so it must not live inside the @@ -91,6 +102,17 @@ function DataTableRowImpl({ const isWholeCardLink = isLinkMode && !hasSubRow; const containerRef = useRef(null); + // Opt-in FLIP: the card becomes a `motion.div` that animates only its + // position (`layout="position"`), so a reorder slides the row without + // distorting the cell content inside it. Plain `
` when off, or until + // framer-motion has resolved — zero cost on the default path. A whole-card + // link row IS its `` and stays a plain link. + const animate = Boolean(animateRowReorder && motionDiv); + const Card: ElementType = animate && motionDiv ? motionDiv : 'div'; + const motionProps = animate + ? { layout: 'position' as const, transition: { layout: { duration: 0.35, ease: [0.22, 1, 0.36, 1] as const } } } + : {}; + const handleClick = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement; @@ -171,10 +193,11 @@ function DataTableRowImpl({ } return ( -
} className={containerClassName} onClick={onClick ? handleClick : undefined} + {...motionProps} > {isLinkMode && href ? ( ({ cells )} {hasSubRow && subRow} -
+ ); } diff --git a/openframe-frontend-core/src/components/ui/data-table/index.ts b/openframe-frontend-core/src/components/ui/data-table/index.ts index 0efd1a6a9..8d987fbbb 100644 --- a/openframe-frontend-core/src/components/ui/data-table/index.ts +++ b/openframe-frontend-core/src/components/ui/data-table/index.ts @@ -52,7 +52,7 @@ export { useDataTable } from './use-data-table'; export { ROW_HEIGHT_DESKTOP, ROW_HEIGHT_MOBILE, ROW_SHELL_CLASSES } from './data-table-skeleton'; export { alignJustify, getHideClasses, multiSelectFilterFn } from './utils'; -export type { DataTableFilterOption, TailwindBreakpoint } from './types'; +export type { DataTableFilterOption, TableFilters, TailwindBreakpoint } from './types'; export type { DataTableProps } from './data-table'; export { DATA_TABLE_HEADER_LABEL_CLASS } from './data-table-header'; export type { DataTableHeaderProps, DataTableSortState } from './data-table-header'; diff --git a/openframe-frontend-core/src/components/ui/data-table/types.ts b/openframe-frontend-core/src/components/ui/data-table/types.ts index 262371f98..c67285fbd 100644 --- a/openframe-frontend-core/src/components/ui/data-table/types.ts +++ b/openframe-frontend-core/src/components/ui/data-table/types.ts @@ -21,6 +21,16 @@ export interface DataTableFilterOption { count?: number; } +/** + * Per-column multi-select filter state: selected option ids keyed by column id, + * the shape `FiltersDropdown` / `FilterModal` emit and `ListPageLayout` passes + * back for its mobile filter. Lives here (not in the legacy `table/`) so the + * filter UI outlives that module. + */ +export interface TableFilters { + [columnKey: string]: string[]; +} + declare module '@tanstack/react-table' { interface ColumnMeta { /** Tailwind width class, e.g. `'w-40'`, `'flex-1 min-w-0'`. */ diff --git a/openframe-frontend-core/src/components/ui/table/use-table-motion.ts b/openframe-frontend-core/src/components/ui/data-table/use-table-motion.ts similarity index 100% rename from openframe-frontend-core/src/components/ui/table/use-table-motion.ts rename to openframe-frontend-core/src/components/ui/data-table/use-table-motion.ts diff --git a/openframe-frontend-core/src/components/ui/filter-modal.tsx b/openframe-frontend-core/src/components/ui/filter-modal.tsx index 3c7fbb78e..d5f95c1c1 100644 --- a/openframe-frontend-core/src/components/ui/filter-modal.tsx +++ b/openframe-frontend-core/src/components/ui/filter-modal.tsx @@ -5,13 +5,13 @@ import { useEffect, useRef, useState } from 'react'; import { cn } from '../../utils/cn'; import { Filter02Icon } from '../icons-v2-generated/sort-and-filter/filter-02-icon'; import { Button } from './button'; +import type { TableFilters } from './data-table/types'; import { DateFilterPanel, type DateFilterResult, type DateRange } from './date-picker'; import { FilterCheckboxItem } from './filter-checkbox-item'; import { ModalV2, ModalV2Content, ModalV2Footer, ModalV2Header, ModalV2Title } from './modal-v2'; import { ScrollFadeOverlay, useScrollFade } from './scroll-fade'; import { Skeleton } from './skeleton'; import { SortColumnItem, type SortConfig, type SortDirection } from './sort-column-item'; -import type { TableFilters } from './table/types'; import { TagKeyValueFilter, type TagKeyConfig } from './tag-key-value-filter'; // Re-export sub-component types for consumers diff --git a/openframe-frontend-core/src/components/ui/query-report-table/query-report-table.tsx b/openframe-frontend-core/src/components/ui/query-report-table/query-report-table.tsx index 7e85ed721..3b5b3dc10 100644 --- a/openframe-frontend-core/src/components/ui/query-report-table/query-report-table.tsx +++ b/openframe-frontend-core/src/components/ui/query-report-table/query-report-table.tsx @@ -5,7 +5,7 @@ import { useHorizontalScrollbar } from '../../../hooks/ui/use-horizontal-scrollb import { cn } from '../../../utils/cn'; import { Download02Icon } from '../../icons-v2-generated/interface/download-02-icon'; import { Button } from '../button'; -import { TableEmptyState } from '../table/table-empty-state'; +import { DataTableEmpty } from '../data-table/data-table-empty'; import { QueryReportTableHeader } from './query-report-table-header'; import { QueryReportTableRow } from './query-report-table-row'; import { QueryReportTableSkeleton } from './query-report-table-skeleton'; @@ -87,7 +87,11 @@ export function QueryReportTable({ )} {/* Empty state */} - {!loading && data.length === 0 && } + {/* Same two-vocabulary rule as `DataTable.Body`: a caller-supplied message is the + title and nothing else, the default keeps the generic search/filter hint. */} + {!loading && + data.length === 0 && + (emptyMessage != null ? : )} {/* Table content */} {!loading && data.length > 0 && ( diff --git a/openframe-frontend-core/src/components/ui/table/table.tsx b/openframe-frontend-core/src/components/ui/table/table.tsx index 6c2f0242a..9f2c2dbe8 100644 --- a/openframe-frontend-core/src/components/ui/table/table.tsx +++ b/openframe-frontend-core/src/components/ui/table/table.tsx @@ -7,12 +7,12 @@ import { Pagination } from '../../pagination'; import { Button } from '../button'; import { CursorPagination } from '../cursor-pagination'; import { PlaceholderRows, ReservedEmptyState } from '../data-table/data-table-skeleton'; +import { useTableMotion } from '../data-table/use-table-motion'; import { TableEmptyState } from './table-empty-state'; import { TableHeader } from './table-header'; import { TableRow } from './table-row'; import { COMPACT_ROW_MIN_HEIGHT, TableCardSkeleton } from './table-skeleton'; import type { RowAction, TableColumn, TableProps, TableRowData } from './types'; -import { useTableMotion } from './use-table-motion'; /** * Injects synthetic columns (row actions and/or row-level chevron link) at the end of the columns array. diff --git a/openframe-frontend-core/src/components/ui/table/types.ts b/openframe-frontend-core/src/components/ui/table/types.ts index 776718096..7bc89ec6a 100644 --- a/openframe-frontend-core/src/components/ui/table/types.ts +++ b/openframe-frontend-core/src/components/ui/table/types.ts @@ -1,4 +1,5 @@ import type { ElementType, ReactNode } from 'react'; +import type { TableFilters } from '../data-table/types'; import type { NoDataProps } from '../no-data'; /** @deprecated Use types from `data-table` instead. */ @@ -48,11 +49,9 @@ export interface FilterSection { allowSelectAll?: boolean; } -/** @deprecated Use types from `data-table` instead. */ -export interface TableFilters { - /** Selected option ids for a column — what `FiltersDropdown` emits. */ - [columnKey: string]: string[]; -} +// `TableFilters` is defined in `data-table/types` (one definition for both tables); +// re-exported so the legacy surface keeps the name until it is deleted. +export type { TableFilters }; /** @deprecated Use types from `data-table` instead. */ export interface CursorPagination { diff --git a/openframe-frontend-core/src/stories/DataTable.stories.tsx b/openframe-frontend-core/src/stories/DataTable.stories.tsx index defb949b4..b17ba9d35 100644 --- a/openframe-frontend-core/src/stories/DataTable.stories.tsx +++ b/openframe-frontend-core/src/stories/DataTable.stories.tsx @@ -1421,3 +1421,98 @@ export const KitchenSink: Story = { ); }, }; + +/** Seeded Fisher-Yates so every "Shuffle" click yields a NEW order (a plain `sort` by seed repeats). */ +function shuffle(items: T[], seed: number): T[] { + const result = [...items]; + let state = seed * 2654435761 + 1; + for (let i = result.length - 1; i > 0; i--) { + state = (state * 1103515245 + 12345) % 2147483648; + const j = state % (i + 1); + [result[i], result[j]] = [result[j] as T, result[i] as T]; + } + return result; +} + +/** + * **Row reorder animation** — opt in with `animateRowReorder` on + * `` and a reorder (same row id, new order) slides rows into + * place via FLIP instead of jumping. framer-motion is loaded lazily, as its own + * chunk, only for tables that set the prop. + * + * `getRowId` is mandatory here: TanStack's default row id is the row INDEX, + * and an index does not move when the data does, so without it nothing would + * animate. Honour `prefers-reduced-motion` at the call site (pass `false`). + */ +export const WithRowReorderAnimation: Story = { + render: () => { + const [sort, setSort] = useState({ id: 'hostname', desc: false }); + const [shuffleSeed, setShuffleSeed] = useState(0); + + const handleSortChange = useCallback((columnId: string) => { + setSort(prev => { + if (prev?.id !== columnId) return { id: columnId, desc: false }; + if (!prev.desc) return { id: columnId, desc: true }; + return null; + }); + }, []); + + const orderedData = useMemo(() => { + if (!sort) return shuffle(DEVICES_8, shuffleSeed); + const dir = sort.desc ? -1 : 1; + return [...DEVICES_8].sort((a, b) => { + const av = a[sort.id as keyof Device] as string | number; + const bv = b[sort.id as keyof Device] as string | number; + if (av < bv) return -1 * dir; + if (av > bv) return 1 * dir; + return 0; + }); + }, [sort, shuffleSeed]); + + const columns = useMemo[]>( + () => [ + { accessorKey: 'hostname', header: 'Hostname', meta: { width: 'w-[200px]', sortable: true } }, + { accessorKey: 'ipAddress', header: 'IP', meta: { width: 'w-[140px]', sortable: true } }, + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => , + meta: { width: 'w-[140px]', sortable: true }, + }, + { + accessorKey: 'cpuLoad', + header: 'CPU', + cell: ({ row }) => , + meta: { width: 'flex-1 min-w-0', align: 'right', sortable: true }, + }, + ], + [], + ); + + const getRowId = useCallback((device: Device) => device.id, []); + const table = useDataTable({ data: orderedData, columns, getRowId }); + + return ( +
+
+ + + Current sort: {JSON.stringify(sort)} + +
+ + + + +
+ ); + }, +}; diff --git a/openframe-frontend-core/src/stories/FilterModal.stories.tsx b/openframe-frontend-core/src/stories/FilterModal.stories.tsx index a70e78763..52a51aa93 100644 --- a/openframe-frontend-core/src/stories/FilterModal.stories.tsx +++ b/openframe-frontend-core/src/stories/FilterModal.stories.tsx @@ -2,8 +2,8 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import { useState } from 'react'; import { fn } from 'storybook/test'; import { Button } from '../components/ui/button'; +import type { TableFilters } from '../components/ui/data-table'; import { FilterModal, type FilterGroup, type SortConfig, type TagKeyConfig } from '../components/ui/filter-modal'; -import type { TableFilters } from '../components/ui/table/types'; const meta = { title: 'UI/FilterModal', diff --git a/openframe-frontend-core/src/stories/ListPageLayout.stories.tsx b/openframe-frontend-core/src/stories/ListPageLayout.stories.tsx index c6b815e87..4da1e34f4 100644 --- a/openframe-frontend-core/src/stories/ListPageLayout.stories.tsx +++ b/openframe-frontend-core/src/stories/ListPageLayout.stories.tsx @@ -1,10 +1,17 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import { LayoutGrid, LayoutList, Plus, RefreshCw } from 'lucide-react'; -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { ListPageLayout } from '../components/layout/list-page-layout'; import { Button } from '../components/ui/button'; +import { + DataTable, + useDataTable, + type ColumnDef, + type ColumnFiltersState, + type OnChangeFn, + type TableFilters, +} from '../components/ui/data-table'; import type { FilterGroup, SortConfig, SortDirection } from '../components/ui/filter-modal'; -import { Table, type TableColumn, type TableFilters } from '../components/ui/table'; interface Device { id: string; @@ -22,32 +29,178 @@ const sampleDevices: Device[] = [ { id: '5', name: 'Linux Server', status: 'online', type: 'Server', lastSeen: '2024-01-20' }, ]; -const deviceColumns: TableColumn[] = [ - { key: 'name', label: 'Device Name' }, +// Module-level so `useDataTable` sees stable `columns` references. The filter +// option ids are the device VALUES, the same ids the mobile `FilterModal` +// groups use below, so one `TableFilters` object drives both controls. +const deviceColumns: ColumnDef[] = [ + { accessorKey: 'name', header: 'Device Name', meta: { width: 'flex-1 min-w-0' } }, { - key: 'type', - label: 'Type', - hideAt: 'lg', - filterable: true, - filterOptions: [ - { id: 'laptop', label: 'Laptop', value: 'laptop' }, - { id: 'mobile', label: 'Mobile', value: 'mobile' }, - { id: 'desktop', label: 'Desktop', value: 'desktop' }, - { id: 'tablet', label: 'Tablet', value: 'tablet' }, - { id: 'server', label: 'Server', value: 'server' }, - ], + accessorKey: 'type', + header: 'Type', + meta: { + width: 'w-[160px]', + hideAt: 'lg', + filter: { + options: [ + { id: 'Laptop', label: 'Laptop', value: 'Laptop' }, + { id: 'Mobile', label: 'Mobile', value: 'Mobile' }, + { id: 'Desktop', label: 'Desktop', value: 'Desktop' }, + { id: 'Tablet', label: 'Tablet', value: 'Tablet' }, + { id: 'Server', label: 'Server', value: 'Server' }, + ], + }, + }, + }, + { + accessorKey: 'status', + header: 'Status', + meta: { + width: 'w-[140px]', + filter: { + options: [ + { id: 'online', label: 'Online', value: 'online' }, + { id: 'offline', label: 'Offline', value: 'offline' }, + { id: 'pending', label: 'Pending', value: 'pending' }, + ], + }, + }, + }, + { accessorKey: 'lastSeen', header: 'Last Seen', meta: { width: 'w-[160px]' } }, +]; + +const NO_FILTERS: TableFilters = {}; + +function rowId(row: { id: string }) { + return row.id; +} + +/** `FilterModal` speaks `TableFilters`; the header funnels speak TanStack's `ColumnFiltersState`. */ +function toColumnFilters(filters: TableFilters): ColumnFiltersState { + return Object.entries(filters) + .filter(([, ids]) => ids.length > 0) + .map(([id, ids]) => ({ id, value: ids })); +} + +function toTableFilters(columnFilters: ColumnFiltersState): TableFilters { + return Object.fromEntries(columnFilters.map(filter => [filter.id, filter.value as string[]])); +} + +/** + * The page body the stories below show: a `DataTable` over the sample devices. + * `filters` / `onFiltersChange` mirror the layout's mobile filter into the + * header funnels (`WithMobileFilter`), so the two controls edit ONE state; the + * data itself is filtered by the story, the table only stores the selection. + */ +function DevicesTable({ + data, + emptyMessage, + filters = NO_FILTERS, + onFiltersChange, +}: { + data: Device[]; + emptyMessage?: string; + filters?: TableFilters; + onFiltersChange?: (filters: TableFilters) => void; +}) { + const columnFilters = useMemo(() => toColumnFilters(filters), [filters]); + const handleColumnFiltersChange = useCallback>( + updater => { + const next = typeof updater === 'function' ? updater(columnFilters) : updater; + onFiltersChange?.(toTableFilters(next)); + }, + [columnFilters, onFiltersChange], + ); + const table = useDataTable({ + data, + columns: deviceColumns, + getRowId: rowId, + state: { columnFilters }, + onColumnFiltersChange: handleColumnFiltersChange, + }); + return ( + + + + + ); +} + +/** Ad-hoc rows for the page examples below: any object with an `id`. */ +function SampleTable({ data, columns }: { data: T[]; columns: ColumnDef[] }) { + const table = useDataTable({ data, columns, getRowId: rowId }); + return ( + + + + + ); +} + +interface Script { + id: string; + name: string; + language: string; + lastRun: string; + status: string; +} + +const sampleScripts: Script[] = [ + { id: '1', name: 'Deploy Script', language: 'Bash', lastRun: '2024-01-20', status: 'success' }, + { id: '2', name: 'Backup Database', language: 'Python', lastRun: '2024-01-19', status: 'success' }, + { id: '3', name: 'Clear Cache', language: 'PowerShell', lastRun: '2024-01-18', status: 'failed' }, +]; + +const scriptColumns: ColumnDef