Files
bface/src/ui/components/grid/table.jsx
2026-04-29 22:05:47 -05:00

315 lines
10 KiB
JavaScript

import React, { useEffect, useRef } from 'react';
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
import { getIcon } from '../IconMapper.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;
return (
<XStack
alignItems="stretch"
borderTopWidth={showTopBorder ? 1 : 0}
borderBottomWidth={1}
borderColor="$lineSubtle"
backgroundColor="transparent"
paddingHorizontal="$2"
>
{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;
return (
<Button
key={column.field}
chromeless
disabled={!column.sortable}
onPress={() => column.sortable && grid.toggleSort(column.field)}
justifyContent={getColumnJustify(column.align)}
alignItems="center"
paddingVertical="$3"
paddingHorizontal="$2"
{...getColumnLayoutStyle(column)}
hoverStyle={column.sortable ? { backgroundColor: '$bgPage' } : undefined}
pressStyle={column.sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
>
<XStack width="100%" alignItems="center" justifyContent={getColumnJustify(column.align)} gap="$2">
<Text
width="auto"
textAlign={column.align || 'left'}
numberOfLines={1}
{...getTypographyRoleProps('tableHeader')}
>
{column.label}
</Text>
{column.sortable ? (
isActive ? (
direction === 'asc'
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
) : (
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
)
) : null}
</XStack>
</Button>
);
})}
</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();
const FirstPageIcon = getIcon('first-page');
const PreviousPageIcon = getIcon('chevron-left');
const NextPageIcon = getIcon('chevron-right');
const LastPageIcon = getIcon('last-page');
if (visible === false) {
return null;
}
return (
<XStack
alignItems="center"
justifyContent="space-between"
gap="$3"
padding="$3"
minHeight={56}
borderTopWidth={1}
borderTopColor="$lineSubtle"
backgroundColor="$bgPanel"
flexWrap="wrap"
>
<Text color="$textMuted">
{grid.total} records
</Text>
<XStack gap="$1" alignItems="center" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage <= 1}
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(1)}
/>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage <= 1}
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.currentPage - 1)}
/>
<Text color="$textSecondary">
Page {grid.currentPage} of {grid.pageCount}
</Text>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage >= grid.pageCount}
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.currentPage + 1)}
/>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage >= grid.pageCount}
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => grid.setPage(grid.pageCount)}
/>
</XStack>
</XStack>
);
}