feat(frontend): sort select + direction toggle above stacked DataTable cards on phones

This commit is contained in:
Juan Denis committed 2026-11-03 13:47:53 -05:00
1 parent a44e3c2435
commit a7ff0bf564
3 files changed
+62 -5

No files matched your search

+40 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronUp, ChevronDown, ChevronRight, Filter } from 'lucide-react';
import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronUp, ChevronDown, ChevronRight, Filter } from 'lucide-react';
import {
Table,
TableBody,
@@ -9,6 +9,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { applyTableSorts, nextSorts, useTableSort } from '@/hooks/useTableSort';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
@@ -19,6 +20,8 @@ import { useTranslation } from 'react-i18next';
import { translateLabel } from '../../i18n/labels';
import { Button as SharedButton } from '@/components/ui/button';
const SORT_NONE = '__none__';
/**
* Declarative data table built on top of the shadcn/ui Table primitives.
*
@@ -46,7 +49,7 @@ import { Button as SharedButton } from '@/components/ui/button';
* The first visible column is the card title, the rest become "label · value"
* rows using the column header as the label, a header-less trailing column
* (actions) becomes a footer row of 40px targets, and the sortable headers
* collapse into a chip row above the cards. Pass `responsive={false}` for
* collapse into one sort select + direction toggle above the cards. Pass `responsive={false}` for
* editor-style grids that must stay tabular on phones.
*
* Example:
@@ -398,8 +401,43 @@ export function DataTable({
);
};
// Phones hide the header, so column sort moves to a select + direction toggle.
const sortColumns = visibleColumns.filter((column) => sortable && column.sortable && typeof column.header === 'string');
const activeSort = sorts[0];
const sortBar = responsive && sortColumns.length > 0 && (
<div className="sk-dtable__sortbar">
<Select
value={activeSort?.key ?? SORT_NONE}
onValueChange={(key) => setSorts(key === SORT_NONE ? [] : [{ key, direction: activeSort?.direction ?? 'asc' }])}
>
<SelectTrigger aria-label={t('app.dataTable.sortBy', 'Sort by')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SORT_NONE}>{t('app.dataTable.defaultOrder', 'Default order')}</SelectItem>
{sortColumns.map((column) => (
<SelectItem key={column.key} value={column.key}>{translateLabel(t, column, 'header')}</SelectItem>
))}
</SelectContent>
</Select>
<SharedButton
type="button"
variant="outline"
size="icon"
disabled={!activeSort}
aria-label={activeSort?.direction === 'desc'
? t('app.dataTable.sortDescending', 'Sorted descending, switch to ascending')
: t('app.dataTable.sortAscending', 'Sorted ascending, switch to descending')}
onClick={() => setSorts([{ key: activeSort.key, direction: activeSort.direction === 'asc' ? 'desc' : 'asc' }])}
>
{activeSort?.direction === 'desc' ? <ArrowDownNarrowWide size={16} /> : <ArrowUpNarrowWide size={16} />}
</SharedButton>
</div>
);
return (
<div className={cn('sk-dtable-wrap', className)} ref={wrapRef}>
{sortBar}
<Table className={cn('sk-dtable', responsive && 'sk-dtable--stack', tableClassName)}>
<TableHeader>
<TableRow>
+5 -1
View File
@@ -1695,10 +1695,14 @@
},
"dataTable": {
"clickToSortShiftClickTo": "Click to sort · Shift+click to add a sort level",
"defaultOrder": "Default order",
"loading": "Loading",
"noRowsMatchTheActiveColumn": "No rows match the active column filters.",
"selectAllRows": "Select all rows",
"selectRow": "Select row"
"selectRow": "Select row",
"sortAscending": "Sorted ascending, switch to descending",
"sortBy": "Sort by",
"sortDescending": "Sorted descending, switch to ascending"
},
"dataTableFooter": {
"nextPage": "Next page",
@@ -502,6 +502,9 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
&__value { display: contents; }
}
// Desktop has the header row for sorting.
.sk-dtable__sortbar { display: none; }
// ---- card mode: rows become stacked cards under 640px --------------------
// The markup is unchanged (DataTable `responsive`, on by default), so sorting,
// selection, grouping and the keyboard cursor all keep working. Opt out with
@@ -514,10 +517,23 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
border-radius: 0;
}
.sk-dtable__sortbar {
display: flex;
align-items: center;
gap: $space-2;
margin-bottom: $space-3;
> [role='combobox'] { flex: 1 1 auto; min-width: 0; height: $control-lg; }
> button { flex: 0 0 auto; width: $control-lg; height: $control-lg; }
}
.sk-dtable--stack {
&, thead, tbody { display: block; }
// Sortable headers become a scrolling chip row above the cards.
// Sorting lives in the .sk-dtable__sortbar above; only the select-all
// checkbox stays up here, as a chip.
thead:not(:has(.sk-dtable__check)) { display: none; }
thead tr {
display: flex;
gap: $space-2;
@@ -539,7 +555,6 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
border-radius: $radius-full;
background: var(--surface);
&.is-sortable,
&.sk-dtable__check { display: inline-flex; }
// Header menus are hover affordances; there is no hover on a phone.