Files
jobtrackingapp/job-tracker-ui/src/components/DashboardGrid.tsx
T

106 lines
7.6 KiB
TypeScript

import React, { useRef, useState } from 'react';
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import GridLayout, { getAllCollisions, noCompactor, verticalCompactor, useContainerWidth, type Compactor, type Layout } from 'react-grid-layout';
import { arrangeDashboardWidgets, type DashboardLayout, type DashboardWidget, WIDGETS } from '../dashboardLayout';
import { useI18n } from '../i18n/I18nProvider';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
const preserveGaps: Compactor = {
...verticalCompactor,
compact(layout, cols) {
// The library resolves collisions; untouched gaps survive mount and reload.
return layout.some(item => getAllCollisions(layout, item).length > 0)
? verticalCompactor.compact(layout, cols) : noCompactor.compact(layout, cols);
},
};
type Props = {
layout: DashboardLayout;
editing: boolean;
disabled: boolean;
onChange: (layout: DashboardLayout) => void;
childrenFor: (widget: DashboardWidget) => React.ReactNode;
};
export default function DashboardGrid({ layout, editing, disabled, onChange, childrenFor }: Props) {
const { t } = useI18n();
const { width, containerRef } = useContainerWidth({ initialWidth: 0 });
const wide = width >= 800;
const [announcement, announce] = useState('');
const focus = useRef<HTMLDivElement>(null);
const contents = new Map(layout.widgets.filter(widget => widget.visible).map(widget => [widget.id, childrenFor(widget)]));
const visible = layout.widgets.filter(widget => widget.visible && contents.get(widget.id) != null);
const update = (id: string, patch: Partial<DashboardWidget>) => {
if (disabled) return;
const widgets = layout.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget);
onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) });
};
const move = (id: string, direction: number) => {
const index = layout.widgets.findIndex(widget => widget.id === id);
const target = index + direction;
if (disabled || target < 0 || target >= layout.widgets.length) return;
const widgets = [...layout.widgets];
const [moved] = widgets.splice(index, 1); widgets.splice(target, 0, moved);
onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) });
announce(t('dashboardMoved', { name: t(WIDGETS.find(widget => widget.id === id)!.titleKey), position: target + 1, total: widgets.length }));
requestAnimationFrame(() => focus.current?.querySelector<HTMLButtonElement>(`[data-dashboard-widget="${id}"] button[data-move]:not(:disabled)`)?.focus());
};
const finish = (next: Layout) => {
if (!editing || disabled) return;
// Mount, compaction and viewport changes never write preferences.
if (next.some(item => item.x < 0 || item.y < 0 || item.x + item.w > 12 || item.y + item.h > 256)) return;
const widgets = layout.widgets.map(widget => {
const item = next.find(item => item.i === widget.id);
return item ? { ...widget, x: item.x, y: item.y, w: item.w, h: item.h, size: item.w === 12 ? 'full' as const : 'standard' as const } : widget;
});
const ordered = widgets.filter(widget => widget.visible).sort((a, b) => a.y - b.y || a.x - b.x);
onChange({ ...layout, widgets: widgets.map(widget => widget.visible ? ordered.shift()! : widget) });
announce(t('assetsUnsavedChanges'));
};
const tiles = visible.map(widget => {
const name = t(WIDGETS.find(item => item.id === widget.id)!.titleKey);
const index = layout.widgets.indexOf(widget);
return <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size}
sx={{ minWidth: 0, display: 'flex', flexDirection: 'column', border: editing ? '1px solid' : undefined, borderColor: 'divider', borderRadius: 2, bgcolor: 'background.paper', overflow: 'hidden' }}>
{editing ? <Stack direction="row" useFlexGap flexWrap="wrap" spacing={0.5} sx={{ p: 1, flexShrink: 0, maxHeight: '65%', overflow: 'auto' }}>
<Button className="dashboard-grid-handle" disabled={disabled || !wide} aria-label={t('dashboardDrag', { name })} aria-describedby="dashboard-grid-help" sx={{ cursor: 'grab', minWidth: 44, minHeight: 44 }}><DragIndicatorIcon /></Button>
<Box component="details" sx={{ flex: 1, minWidth: 0 }}>
<Box component="summary" sx={{ cursor: 'pointer', p: 1, minHeight: 44 }}>{name}</Box>
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1}>
{[-1, 1].map(direction => <Button key={direction} data-move={direction} disabled={disabled || index + direction < 0 || index + direction >= layout.widgets.length}
aria-label={t(direction < 0 ? 'dashboardMoveEarlierNamed' : 'dashboardMoveLaterNamed', { name })} onClick={() => move(widget.id, direction)}>{t(direction < 0 ? 'dashboardEarlier' : 'dashboardLater')}</Button>)}
<TextField select label={t('dashboardSize')} value={widget.size} disabled={disabled || widget.id === 'cards'}
slotProps={{ select: { native: true }, htmlInput: { 'aria-label': t('dashboardSizeFor', { name }) } }}
onChange={event => update(widget.id, { size: event.target.value as 'standard' | 'full', w: event.target.value === 'full' ? 12 : 6 })}>
<option value="standard">{t('dashboardSizeStandard')}</option><option value="full">{t('dashboardSizeFull')}</option>
</TextField>
<TextField type="number" label={t('dashboardHeightFor', { name })} value={widget.h} disabled={disabled} sx={{ width: 110 }}
slotProps={{ htmlInput: { min: 4, max: 24, step: 1 } }} onChange={event => { const h = Number(event.target.value); if (Number.isInteger(h) && h >= 4 && h <= 24) update(widget.id, { h }); }} />
<Button disabled={disabled} onClick={() => {
update(widget.id, { visible: false });
announce(t('dashboardWidgetHidden', { name }));
requestAnimationFrame(() => focus.current?.focus());
}}>{t('dashboardHideWidget')}</Button>
</Stack>
</Box>
</Stack> : null}
<Box tabIndex={wide ? 0 : undefined} role={wide ? 'group' : undefined} aria-label={wide ? name : undefined}
sx={{ minHeight: 0, minWidth: 0, flex: 1, overflow: wide ? 'auto' : 'visible', p: editing ? 1 : 0 }}>{contents.get(widget.id)}</Box>
</Box>;
});
return <Box ref={containerRef} sx={{ minWidth: 0 }}>
{editing ? <Typography id="dashboard-grid-help" variant="body2">{t('dashboardGridHelp')}</Typography> : null}
<Typography role="status" aria-live="polite" sx={{ position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' }}>{announcement}</Typography>
<Box ref={focus} tabIndex={editing ? -1 : undefined} role={editing ? 'region' : undefined} aria-label={editing ? t('dashboardPreview') : undefined}
sx={{ '& .react-resizable-handle': { width: 44, height: 44 }, '@media (prefers-reduced-motion: reduce)': { '& .react-grid-item': { transition: 'none' } } }}>
{wide ? <GridLayout width={width} compactor={preserveGaps} layout={visible.map(widget => ({ i: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h, minW: widget.id === 'cards' ? 12 : 4, maxW: 12, minH: 4, maxH: 24 }))}
gridConfig={{ cols: 12, rowHeight: 40, margin: [16, 16], containerPadding: [0, 0], maxRows: 256 }}
dragConfig={{ enabled: editing && !disabled, handle: '.dashboard-grid-handle', allowMobileScroll: true }}
resizeConfig={{ enabled: editing && !disabled, handles: ['se'] }} onDragStop={finish} onResizeStop={finish}>{tiles}</GridLayout>
: <Stack spacing={3}>{tiles}</Stack>}
</Box>
</Box>;
}