308 lines
9.6 KiB
JavaScript
308 lines
9.6 KiB
JavaScript
import React, { useEffect, useRef } from 'react';
|
|
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
|
|
import { getIcon } from '../IconMapper.jsx';
|
|
import { PageNavBar } from '../PageNavBar.jsx';
|
|
import { useGridView } from './context.js';
|
|
import { getTypographyRoleProps } from '../../styles/index.js';
|
|
import {
|
|
formatValueByColumn,
|
|
getColumnJustify,
|
|
getColumnLayoutStyle,
|
|
resolveCellAlignment,
|
|
resolveCellValue
|
|
} from './utils.js';
|
|
|
|
function DefaultGridCellRenderer({ value, column }) {
|
|
return (
|
|
<Text width="100%" textAlign={column.align || 'left'} color="$textPrimary" opacity={value == null || value === '' ? 0.6 : 1}>
|
|
{formatValueByColumn(value, column)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
function UtilityCell({ rowId }) {
|
|
const grid = useGridView();
|
|
const ChevronRightIcon = getIcon('chevron-right');
|
|
|
|
if (!grid.selectable && !grid.nested) {
|
|
return null;
|
|
}
|
|
|
|
if (grid.nested) {
|
|
return (
|
|
<XStack width={36} alignItems="center" justifyContent="center">
|
|
{ChevronRightIcon ? <ChevronRightIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">{'>'}</Text>}
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<XStack width={36} alignItems="center" justifyContent="center">
|
|
<Checkbox
|
|
checked={grid.selectedIds.has(rowId)}
|
|
onCheckedChange={() => grid.toggleSelectRow(rowId)}
|
|
borderColor="$lineStrong"
|
|
backgroundColor="$bgPanel"
|
|
focusStyle={{ borderColor: '$accent' }}
|
|
>
|
|
<Checkbox.Indicator>
|
|
{(() => {
|
|
const Check = getIcon('check');
|
|
return Check ? <Check size="sm" color="$accent" /> : null;
|
|
})()}
|
|
</Checkbox.Indicator>
|
|
</Checkbox>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
function useViewportTracking(enabled = true) {
|
|
const grid = useGridView();
|
|
const bodyRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || typeof window === 'undefined') {
|
|
return undefined;
|
|
}
|
|
|
|
const updateViewportWidth = () => {
|
|
const element = bodyRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
const nextWidth = Math.max(0, Math.round(element.getBoundingClientRect().width));
|
|
grid.setTableViewportWidth((current) => (current === nextWidth ? current : nextWidth));
|
|
};
|
|
|
|
updateViewportWidth();
|
|
window.addEventListener('resize', updateViewportWidth);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', updateViewportWidth);
|
|
};
|
|
}, [enabled, grid]);
|
|
|
|
return bodyRef;
|
|
}
|
|
|
|
export function TableHeader({ visible = true, showTopBorder = true }) {
|
|
const grid = useGridView();
|
|
|
|
if (visible === false) {
|
|
return null;
|
|
}
|
|
|
|
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
|
|
const CaretUp = getIcon('caret-up');
|
|
const CaretDown = getIcon('caret-down');
|
|
const allIds = (grid.rows || []).map((row) => row?.id).filter((id) => id != null);
|
|
const selectedCount = allIds.filter((id) => grid.selectedIds.has(id)).length;
|
|
const allSelected = allIds.length > 0 && selectedCount === allIds.length;
|
|
const someSelected = selectedCount > 0 && !allSelected;
|
|
const getNextSortDirection = (column) => {
|
|
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
|
|
if (!activeSort) {
|
|
return 'ascending';
|
|
}
|
|
if (activeSort.direction === 'asc') {
|
|
return 'descending';
|
|
}
|
|
return 'none';
|
|
};
|
|
|
|
return (
|
|
<XStack
|
|
alignItems="stretch"
|
|
borderTopWidth={showTopBorder ? 1 : 0}
|
|
borderBottomWidth={1}
|
|
borderColor="$lineSubtle"
|
|
backgroundColor="$bgPanel"
|
|
paddingHorizontal="$2"
|
|
paddingTop="$1"
|
|
paddingBottom="$1"
|
|
gap="$1"
|
|
>
|
|
{grid.selectable || grid.nested ? (
|
|
<XStack width={36} alignItems="center" justifyContent="center">
|
|
{grid.selectable ? (
|
|
<Checkbox
|
|
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
|
|
onCheckedChange={() => grid.toggleSelectAll?.()}
|
|
borderColor="$lineStrong"
|
|
backgroundColor="$bgPanel"
|
|
focusStyle={{ borderColor: '$accent' }}
|
|
>
|
|
<Checkbox.Indicator>
|
|
{(() => {
|
|
const Check = getIcon('check');
|
|
return Check ? <Check size="sm" color="$accent" /> : null;
|
|
})()}
|
|
</Checkbox.Indicator>
|
|
</Checkbox>
|
|
) : null}
|
|
</XStack>
|
|
) : null}
|
|
{activeColumns.map((column) => {
|
|
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
|
|
const isActive = Boolean(activeSort);
|
|
const direction = activeSort?.direction;
|
|
const sortButtonLabel = `Sort ${column.label} ${getNextSortDirection(column)}`;
|
|
const ChevronIcon = isActive
|
|
? (direction === 'asc' ? CaretUp : CaretDown)
|
|
: CaretDown;
|
|
const iconColor = isActive ? '$textSecondary' : '$textMuted';
|
|
|
|
return (
|
|
<XStack
|
|
key={column.field}
|
|
justifyContent={getColumnJustify(column.align)}
|
|
alignItems="center"
|
|
minHeight={44}
|
|
paddingHorizontal="$2"
|
|
paddingVertical="$2"
|
|
gap="$2"
|
|
{...getColumnLayoutStyle(column)}
|
|
>
|
|
<Text
|
|
flex={1}
|
|
minWidth={0}
|
|
textAlign={column.align || 'left'}
|
|
numberOfLines={1}
|
|
{...getTypographyRoleProps('tableHeader')}
|
|
>
|
|
{column.label}
|
|
</Text>
|
|
{column.sortable ? (
|
|
<Button
|
|
chromeless
|
|
circular
|
|
size="$2"
|
|
flexShrink={0}
|
|
aria-label={sortButtonLabel}
|
|
onPress={() => grid.toggleSort(column.field)}
|
|
hoverStyle={{ backgroundColor: '$bgPage' }}
|
|
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
|
icon={ChevronIcon ? <ChevronIcon size="xs" color={iconColor} /> : undefined}
|
|
/>
|
|
) : null}
|
|
</XStack>
|
|
);
|
|
})}
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
export function TableBodyView({ visible = true }) {
|
|
const grid = useGridView();
|
|
const bodyRef = useViewportTracking(visible);
|
|
|
|
if (visible === false) {
|
|
return null;
|
|
}
|
|
|
|
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
|
|
|
|
if (grid.error) {
|
|
return (
|
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
|
<Text color="$danger" fontWeight="600">{grid.error}</Text>
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollView flex={1}>
|
|
<YStack ref={bodyRef}>
|
|
{grid.rows.map((row, index) => (
|
|
<YStack key={row.id ?? index}>
|
|
<XStack
|
|
alignItems="stretch"
|
|
paddingHorizontal="$2"
|
|
backgroundColor={grid.selectedIds.has(row.id) ? '$accentBg' : index % 2 === 1 ? '$bgPage' : 'transparent'}
|
|
hoverStyle={{ backgroundColor: '$bgPage' }}
|
|
>
|
|
{grid.selectable || grid.nested ? <UtilityCell rowId={row.id} /> : null}
|
|
{activeColumns.map((column) => {
|
|
const Renderer = column.renderer || DefaultGridCellRenderer;
|
|
const cellValue = resolveCellValue(row, column);
|
|
|
|
return (
|
|
<XStack
|
|
key={`${row.id}-${column.field}`}
|
|
alignItems="center"
|
|
justifyContent={getColumnJustify(resolveCellAlignment(column))}
|
|
paddingVertical="$3"
|
|
paddingHorizontal="$2"
|
|
minHeight={46}
|
|
{...getColumnLayoutStyle(column)}
|
|
>
|
|
<Renderer value={cellValue} row={row} column={column} grid={grid} />
|
|
</XStack>
|
|
);
|
|
})}
|
|
</XStack>
|
|
<Separator borderColor="$lineSubtle" />
|
|
</YStack>
|
|
))}
|
|
|
|
{!grid.rows.length && !grid.isLoading ? (
|
|
<YStack minHeight={120} alignItems="center" justifyContent="center" padding="$5">
|
|
<Text color="$textSecondary" fontWeight="600">No records available</Text>
|
|
<Text color="$textMuted" fontSize="$3">There's nothing to show here yet.</Text>
|
|
</YStack>
|
|
) : null}
|
|
|
|
{grid.isLoading && !grid.rows.length ? (
|
|
<YStack minHeight={64} padding="$4" gap="$2">
|
|
{[0, 1, 2].map((i) => (
|
|
<XStack key={i} height={46} borderRadius="$radiusMd" backgroundColor="$bgPage" />
|
|
))}
|
|
</YStack>
|
|
) : null}
|
|
</YStack>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
export function TableFooter({ visible = true }) {
|
|
const grid = useGridView();
|
|
|
|
if (visible === false) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<XStack
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
gap="$3"
|
|
paddingTop="$1"
|
|
paddingRight="$3"
|
|
paddingBottom="$1"
|
|
paddingLeft="$3"
|
|
minHeight={64}
|
|
borderTopWidth={1}
|
|
borderTopColor="$lineSubtle"
|
|
backgroundColor="$bgPanel"
|
|
flexWrap="wrap"
|
|
>
|
|
<Text color="$textMuted">
|
|
{grid.total} records
|
|
</Text>
|
|
|
|
<PageNavBar
|
|
outlined={false}
|
|
label={`Page ${grid.currentPage} of ${grid.pageCount}`}
|
|
onFirstPage={() => grid.setPage(1)}
|
|
onPreviousPage={() => grid.setPage(grid.currentPage - 1)}
|
|
onNextPage={() => grid.setPage(grid.currentPage + 1)}
|
|
onLastPage={() => grid.setPage(grid.pageCount)}
|
|
firstDisabled={grid.currentPage <= 1}
|
|
previousDisabled={grid.currentPage <= 1}
|
|
nextDisabled={grid.currentPage >= grid.pageCount}
|
|
lastDisabled={grid.currentPage >= grid.pageCount}
|
|
/>
|
|
</XStack>
|
|
);
|
|
}
|