Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/components/data_table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ Horizontal scrolling belongs to the table. Under a bounded width it lays out
at `max(minimumWidth, availableWidth)` inside a horizontal viewport, so flex
columns never resolve against unbounded constraints and a narrow viewport
scrolls instead of overflowing. Vertical scrolling, sticky headers, and
viewport height stay with the parent.
viewport height stay with the parent. The pagination footer stays pinned to
the visible width while the header and body scroll.

## Accessibility

Expand Down
2 changes: 1 addition & 1 deletion packages/remix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,9 @@ Remix provides a comprehensive set of production-ready components:
- **Card** - Content containers
- **DataList** - Label/value metadata lists with a shared label column
- **DataTable** - Controlled tables with shared column headers, sorting, selection, and pagination
- **Skeleton** - Loading placeholders that mirror their content
- **Divider** - Visual separators
- **Progress** - Progress indicators
- **Skeleton** - Loading placeholders that mirror their content
- **Spinner** - Loading states

### Layout & Navigation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ class DataTableSpec with _$DataTableSpec {
/// Null keeps the last row identical to the others. Radix's surface variant
/// is the one real consumer: it drops the trailing divider so the last row
/// does not double up with the panel border.
///
/// On the styler path the override merges over [bodyRow]; a raw
/// [RemixDataTable.styleSpec] carries resolved specs, which cannot merge,
/// so there a non-null value replaces [bodyRow] wholesale and must be
/// pre-composed by the caller.
@override
final StyleSpec<BoxSpec>? lastBodyRow;

Expand Down
66 changes: 55 additions & 11 deletions packages/remix/lib/src/components/data_table/data_table_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ final class RemixDataTableLabels {
/// table is laid out at `max(minimumWidth, availableWidth)` inside a
/// horizontal viewport, so flex columns never resolve against unbounded
/// constraints. Vertical scrolling, sticky headers, and viewport height stay
/// with the parent.
/// with the parent. The pagination footer stays pinned to the visible width
/// while the header and body scroll.
///
/// ## Example
///
Expand Down Expand Up @@ -355,6 +356,15 @@ class RemixDataTable<T> extends StatelessWidget {
'RemixDataTable.minimumWidth must be a finite non-negative value.',
);

// StyleBuilder merges StyleProvider-inherited styles into the resolved
// spec; merge them here too so per-row re-resolution sees the same
// widget-state variants. Other Style subtypes (IdentityStyle) carry no
// props to re-resolve and are already represented in the spec fallback.
final inherited = Style.maybeOf<DataTableSpec>(context);
final effectiveStyler = inherited is DataTableStyler
? inherited.merge(style)
: style;

return RemixStyleSpecBuilder<DataTableSpec>(
style: style,
styleSpec: styleSpec,
Expand All @@ -368,7 +378,7 @@ class RemixDataTable<T> extends StatelessWidget {
// A supplied raw spec has no styler to re-resolve per row, so the
// table-level values are the final ones in that path.
styles: _DataTableStyles(
styler: styleSpec == null ? style : null,
styler: styleSpec == null ? effectiveStyler : null,
spec: spec,
),
),
Expand Down Expand Up @@ -464,6 +474,11 @@ class RemixDataTable<T> extends StatelessWidget {
if (total == null) return true;
assert(total >= 0, 'RemixDataTable.totalRows must not be negative.');
assert(pageSize > 0, 'RemixDataTable.pageSize must be positive.');
assert(
rows.length <= pageSize,
'RemixDataTable.rows has ${rows.length} rows, which exceeds pageSize '
'($pageSize): rows must be exactly the visible page.',
);
assert(
sizeOptions.isNotEmpty && sizeOptions.every((option) => option > 0),
'RemixDataTable.pageSizeOptions must be nonempty and positive.',
Expand Down Expand Up @@ -582,6 +597,18 @@ class _RemixDataTableView<T> extends StatefulWidget {
class _RemixDataTableViewState<T> extends State<_RemixDataTableView<T>> {
int? _hoveredRow;

@override
void didUpdateWidget(covariant _RemixDataTableView<T> oldWidget) {
super.didUpdateWidget(oldWidget);
// Hover is tracked by row index and MouseRegion.onExit does not fire for
// a region unmounted while hovered, so new row content invalidates the
// index. The mouse tracker re-enters the correct row on the next frame
// when the pointer is still over one.
if (_hoveredRow != null && !listEquals(widget.rows, oldWidget.rows)) {
_hoveredRow = null;
}
}

RemixDataTable<T> get _table => widget.table;

bool get _selectable => _table._selectable;
Expand Down Expand Up @@ -670,7 +697,7 @@ class _RemixDataTableViewState<T> extends State<_RemixDataTableView<T>> {
'DataTableSpec dimensions must resolve to non-negative values.',
);

final content = Column(
final tableContent = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expand All @@ -681,7 +708,6 @@ class _RemixDataTableViewState<T> extends State<_RemixDataTableView<T>> {
// semantics stay caller-owned.
if (widget.rows.isEmpty && _table.emptyBuilder != null)
_table.emptyBuilder!(context),
if (_table._paginated) _buildFooter(context),
],
);

Expand All @@ -699,17 +725,35 @@ class _RemixDataTableViewState<T> extends State<_RemixDataTableView<T>> {
return IntrinsicWidth(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: _table.minimumWidth),
child: content,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
tableContent,
if (_table._paginated) _buildFooter(context),
],
),
),
);
}

return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: math.max(_table.minimumWidth, available),
child: content,
),
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: math.max(_table.minimumWidth, available),
child: tableContent,
),
),
// Deliberate: the footer is a sibling of the scroller, matching
// PaginatedDataTable — pagination stays visible while the table
// scrolls. Its top border spans the viewport, not the laid-out
// table width.
if (_table._paginated) _buildFooter(context),
],
);
},
),
Expand Down
105 changes: 104 additions & 1 deletion packages/remix/test/components/data_table/data_table_style_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ List<Color?> _boxColors(WidgetTester tester) {
}

Widget _table({
List<_Record> rows = _records,
DataTableStyler? style,
DataTableSpec? styleSpec,
bool selectable = false,
Expand All @@ -59,7 +60,7 @@ Widget _table({
List<RemixDataTableColumn<_Record>>? columns,
}) {
return RemixDataTable<_Record>(
rows: _records,
rows: rows,
columns: columns ?? _columns(),
minimumWidth: minimumWidth,
rowId: selectable ? (row) => row.id : null,
Expand Down Expand Up @@ -142,6 +143,36 @@ void main() {
expect(tester.takeException(), isNull);
});

testWidgets(
'keeps pagination controls in the viewport while table scrolls',
(tester) async {
const viewportKey = ValueKey('data-table-viewport');
await tester.pumpRemixApp(
SizedBox(
key: viewportKey,
width: 500,
child: RemixDataTable<_Record>(
rows: _records,
columns: _columns(),
minimumWidth: 640,
totalRows: 42,
onPageChanged: (_) {},
onPageSizeChanged: (_) {},
),
),
);

expect(tester.getSize(_tableFinder).width, 640);
final viewport = tester.getRect(find.byKey(viewportKey));
expect(
tester
.getRect(find.byKey(const ValueKey('remix-data-table-next-page')))
.right,
lessThanOrEqualTo(viewport.right),
);
},
);

testWidgets('resolves flex columns under an unbounded parent', (
tester,
) async {
Expand Down Expand Up @@ -327,6 +358,78 @@ void main() {
expect(colors.where((c) => c == const Color(0xFF777777)), hasLength(2));
});

testWidgets('provider-inherited stylers resolve on a hovered row', (
tester,
) async {
await tester.pumpRemixApp(
StyleProvider<DataTableSpec>(
style: DataTableStyler().bodyRow(
BoxStyler()
.color(const Color(0xFF777777))
.onHovered(BoxStyler().color(const Color(0xFF999999))),
),
child: SizedBox(width: 400, child: _table()),
),
);

final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(
pointer.hover(tester.getCenter(find.text('Ada'))),
);
await tester.pump();

final colors = _boxColors(tester);
expect(colors.where((c) => c == const Color(0xFF999999)), hasLength(2));
expect(colors.where((c) => c == const Color(0xFF777777)), hasLength(2));
});

testWidgets('clears a hovered row index when row content changes', (
tester,
) async {
final style = DataTableStyler().bodyRow(
BoxStyler()
.color(const Color(0xFF777777))
.onHovered(BoxStyler().color(const Color(0xFF999999))),
);
final c = _Record('c', 'Curie');

await tester.pumpRemixApp(
SizedBox(width: 400, child: _table(style: style)),
);

final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(
pointer.hover(tester.getCenter(find.text('Blaise'))),
);
await tester.pump();
expect(
_boxColors(tester).where((c) => c == const Color(0xFF999999)),
hasLength(2),
);

await tester.pumpRemixApp(
SizedBox(
width: 400,
child: _table(rows: [_records.first], style: style),
),
);
// Once the hovered MouseRegion is gone, moving the pointer cannot fire
// its onExit callback.
await tester.sendEventToBinding(pointer.hover(const Offset(1000, 1000)));
await tester.pump();
await tester.pumpRemixApp(
SizedBox(
width: 400,
child: _table(rows: [_records.first, c], style: style),
),
);

expect(
_boxColors(tester).where((c) => c == const Color(0xFF999999)),
isEmpty,
);
});

testWidgets('selection visuals do not change row geometry', (tester) async {
final style = DataTableStyler()
.bodyRow(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,31 @@ void main() {
expect(find.byType(RemixIconButton), findsNothing);
});

testWidgets('rejects a page with more rows than its page size', (
tester,
) async {
await tester.pumpRemixApp(
RemixDataTable<_Record>(
rows: _records,
columns: _columns(),
totalRows: 3,
pageSize: 2,
pageSizeOptions: const [2],
onPageChanged: (_) {},
onPageSizeChanged: (_) {},
),
);

expect(
tester.takeException(),
isA<AssertionError>().having(
(error) => error.message,
'message',
contains('exceeds pageSize'),
),
);
});

testWidgets('keeps pagination outside the structural table node', (
tester,
) async {
Expand Down
Loading