Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = unknown> {
/** Show skeleton rows while `loading` is true and data is empty. */
Expand Down Expand Up @@ -68,6 +69,25 @@ export interface DataTableBodyProps<T = unknown> {
* 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 `<Link>` (`rowHref` without `onRowClick`, no
* sub-row) are not animated.
*/
animateRowReorder?: boolean;
}

/**
Expand All @@ -90,9 +110,13 @@ export function DataTableBody<T = unknown>({
rowHref,
minRows,
renderSubRow,
animateRowReorder,
}: DataTableBodyProps<T>) {
const table = useDataTableContext<T>();
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 (
Expand Down Expand Up @@ -132,26 +156,37 @@ export function DataTableBody<T = unknown>({

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 (
<DataTableRow<T>
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
// `<div>`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 (
<div className={cn('flex w-full flex-col gap-[var(--spacing-system-xsf)]', className)}>
{rows.map((row, index) => {
const item = row.original;
const href = rowHref?.(item) ?? undefined;
const cls = typeof rowClassName === 'function' ? rowClassName(item, index) : rowClassName;
return (
<DataTableRow<T>
key={row.id}
row={row}
onClick={onRowClick}
href={href}
compact={compact}
autoHeight={autoHeight}
rowHeightClassName={rowHeightClassName}
className={cls}
subRow={renderSubRow?.(item)}
/>
);
})}
{animateRowReorder && LayoutGroup ? <LayoutGroup>{rowNodes}</LayoutGroup> : rowNodes}
{padCount > 0 && <PlaceholderRows count={padCount} rowHeightClassName={rowHeightClassName} />}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,6 +35,15 @@ export interface DataTableRowProps<T> {
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 `<div>` is used until it's set.
*/
motionDiv?: ElementType;
}

/**
Expand Down Expand Up @@ -83,6 +92,8 @@ function DataTableRowImpl<T>({
rowHeightClassName,
className,
subRow,
animateRowReorder,
motionDiv,
}: DataTableRowProps<T>) {
const hasSubRow = subRow != null && subRow !== false;
// A sub-row carries its own interactive controls, so it must not live inside the
Expand All @@ -91,6 +102,17 @@ function DataTableRowImpl<T>({
const isWholeCardLink = isLinkMode && !hasSubRow;
const containerRef = useRef<HTMLElement | null>(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 `<div>` when off, or until
// framer-motion has resolved — zero cost on the default path. A whole-card
// link row IS its `<Link>` 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;
Expand Down Expand Up @@ -171,10 +193,11 @@ function DataTableRowImpl<T>({
}

return (
<div
<Card
ref={containerRef as React.RefObject<HTMLDivElement>}
className={containerClassName}
onClick={onClick ? handleClick : undefined}
{...motionProps}
>
{isLinkMode && href ? (
<Link
Expand All @@ -192,7 +215,7 @@ function DataTableRowImpl<T>({
cells
)}
{hasSubRow && subRow}
</div>
</Card>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 10 additions & 0 deletions openframe-frontend-core/src/components/ui/data-table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TData extends RowData, TValue> {
/** Tailwind width class, e.g. `'w-40'`, `'flex-1 min-w-0'`. */
Expand Down
2 changes: 1 addition & 1 deletion openframe-frontend-core/src/components/ui/filter-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,7 +87,11 @@ export function QueryReportTable({
)}

{/* Empty state */}
{!loading && data.length === 0 && <TableEmptyState message={emptyMessage} />}
{/* 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 ? <DataTableEmpty title={emptyMessage} description={undefined} /> : <DataTableEmpty />)}

{/* Table content */}
{!loading && data.length > 0 && (
Expand Down
2 changes: 1 addition & 1 deletion openframe-frontend-core/src/components/ui/table/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 4 additions & 5 deletions openframe-frontend-core/src/components/ui/table/types.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
95 changes: 95 additions & 0 deletions openframe-frontend-core/src/stories/DataTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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
* `<DataTable.Body>` 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<DataTableSortState | null>({ 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<ColumnDef<Device>[]>(
() => [
{ 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 }) => <StatusTag status={row.original.status} />,
meta: { width: 'w-[140px]', sortable: true },
},
{
accessorKey: 'cpuLoad',
header: 'CPU',
cell: ({ row }) => <LoadCell value={row.original.cpuLoad} />,
meta: { width: 'flex-1 min-w-0', align: 'right', sortable: true },
},
],
[],
);

const getRowId = useCallback((device: Device) => device.id, []);
const table = useDataTable<Device>({ data: orderedData, columns, getRowId });

return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={() => {
setSort(null);
setShuffleSeed(seed => seed + 1);
}}
>
Shuffle
</Button>
<span className="text-ods-text-secondary text-h5">
Current sort: <code>{JSON.stringify(sort)}</code>
</span>
</div>
<DataTable table={table}>
<DataTable.Header sort={sort} onSortChange={handleSortChange} />
<DataTable.Body animateRowReorder />
</DataTable>
</div>
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading