diff --git a/README.md b/README.md index 89ebdf17..427e60dc 100644 --- a/README.md +++ b/README.md @@ -286,8 +286,11 @@ Remix provides a comprehensive set of production-ready components: - **Avatar** - User avatars and images - **Badge** - Status indicators and labels - **Card** - Content containers +- **DataList** - Label/value metadata lists with a shared label column +- **DataTable** - Controlled tables with shared column headers, sorting, selection, and pagination - **Divider** - Visual separators - **Progress** - Progress indicators +- **Skeleton** - Loading placeholders that mirror their content - **Spinner** - Loading states ### Layout & Navigation diff --git a/docs.json b/docs.json index 22b5ea24..d4081a8e 100644 --- a/docs.json +++ b/docs.json @@ -113,6 +113,10 @@ "title": "Data List", "href": "/components/data_list" }, + { + "title": "Data Table", + "href": "/components/data_table" + }, { "title": "Dialog", "href": "/components/dialog" diff --git a/docs/components/data_table.mdx b/docs/components/data_table.mdx new file mode 100644 index 00000000..c973ebc5 --- /dev/null +++ b/docs/components/data_table.mdx @@ -0,0 +1,486 @@ +--- +title: Data Table +description: A controlled table that compares many records under shared column headers, with caller-owned sorting, selection, and pagination +keywords: [flutter, remix, data table, table, grid, rows, columns, sorting, selection, pagination] +--- + +A table for comparing many records under shared column headers. One bounded +page of rows is laid out with Flutter's core `Table`, so every row negotiates +the same column widths and the native table/row/cell accessibility roles come +from the framework itself. + +## Data Table versus Data List + +They describe different shapes of data, so they are separate components: + +- **[Data List](/components/data_list)** describes **one** record as + label/value metadata. Every row is a different field of the same subject. +- **Data Table** compares **many** records under shared column headers. Every + row is a different subject and every column is the same field. + +Reach for Data List on a detail page and Data Table on an index page. + +## When to use this + +- **Index and management screens**: Customers, orders, invoices, members +- **Comparable records**: Anything where the same fields repeat per row +- **Server-paginated results**: You already hold one page of rows +- **Caller-owned data operations**: You sort, filter, and slice the data + +Data Table is not a spreadsheet or a virtualized grid. Filtering UI, +multi-column sort, column resizing or reordering, frozen rows, virtualization, +and cell editing are out of scope. + +## Basic implementation + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class Person { + const Person(this.id, this.name, this.role); + + final String id; + final String name; + final String role; +} + +class DataTableExample extends StatelessWidget { + const DataTableExample({super.key}); + + static const people = [ + Person('1', 'Leo Farias', 'Owner'), + Person('2', 'Ada Lovelace', 'Engineer'), + ]; + + @override + Widget build(BuildContext context) { + return FortalDataTable.surface( + semanticLabel: 'Team members', + rows: people, + columns: [ + RemixDataTableColumn( + id: 'name', + label: 'Name', + cellBuilder: (context, row) => Text(row.name), + ), + RemixDataTableColumn( + id: 'role', + label: 'Role', + width: const FixedColumnWidth(140), + cellBuilder: (context, row) => Text(row.role), + ), + ], + ); + } +} +``` + + +## Controlled sorting + +Sorting is a signal, not behavior. Mark a column `sortable`, hold the +descriptor in your state, and sort the rows yourself. Activating a header +cycles ascending and descending and emits a new descriptor; the table never +reorders the rows it was given. + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class SortableTableExample extends StatefulWidget { + const SortableTableExample({super.key, required this.names}); + + final List names; + + @override + State createState() => _SortableTableExampleState(); +} + +class _SortableTableExampleState extends State { + RemixDataTableSort _sort = const RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.ascending, + ); + + @override + Widget build(BuildContext context) { + final rows = List.of(widget.names)..sort(); + if (_sort.direction == RemixDataTableSortDirection.descending) { + rows.setAll(0, rows.reversed.toList()); + } + + return FortalDataTable( + rows: rows, + sort: _sort, + onSortChanged: (sort) => setState(() => _sort = sort), + columns: [ + RemixDataTableColumn( + id: 'name', + label: 'Name', + sortable: true, + cellBuilder: (context, row) => Text(row), + ), + ], + ); + } +} +``` + + +## Selection and pagination + +Selection turns on when both `rowId` and `onSelectionChanged` are supplied, +and pagination turns on when `totalRows`, `onPageChanged`, and +`onPageSizeChanged` are all supplied. Supplying only part of either group is +rejected in debug builds rather than producing half-working behavior. + +`rows` is already the current page: the table never slices it. Select-all is +scoped to the visible page and preserves selections made on other pages. + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class PagedTableExample extends StatefulWidget { + const PagedTableExample({super.key, required this.allRows}); + + final List allRows; + + @override + State createState() => _PagedTableExampleState(); +} + +class _PagedTableExampleState extends State { + Set _selected = {}; + int _pageIndex = 0; + int _pageSize = 10; + + @override + Widget build(BuildContext context) { + final page = widget.allRows.skip(_pageIndex * _pageSize).take(_pageSize); + + return FortalDataTable.surface( + rows: page.toList(), + rowId: (row) => row, + selectedRowIds: _selected, + onSelectionChanged: (ids) => setState(() => _selected = ids), + totalRows: widget.allRows.length, + pageIndex: _pageIndex, + pageSize: _pageSize, + onPageChanged: (index) => setState(() => _pageIndex = index), + onPageSizeChanged: (size) => setState(() { + _pageSize = size; + _pageIndex = 0; + }), + columns: [ + RemixDataTableColumn( + id: 'value', + label: 'Value', + cellBuilder: (context, row) => Text(row), + ), + ], + ); + } +} +``` + + +## Custom cells, headers, and the empty state + +A cell builder returns any widget and keeps its own semantics and gestures, so +badges, avatars, and action menus stay usable. A column with a custom `header` +must also supply a `semanticLabel`, because the header's accessibility node +replaces the announcement of its visible content. `emptyBuilder` replaces the +body rows while the column header and pagination footer stay in place. + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class ActionCellExample extends StatelessWidget { + const ActionCellExample({super.key, required this.rows}); + + final List rows; + + @override + Widget build(BuildContext context) { + return FortalDataTable.surface( + rows: rows, + emptyBuilder: (context) => const Padding( + padding: EdgeInsets.all(24), + child: Text('No results found'), + ), + columns: [ + RemixDataTableColumn( + id: 'value', + label: 'Value', + cellBuilder: (context, row) => Text(row), + ), + RemixDataTableColumn( + id: 'actions', + header: const SizedBox.shrink(), + semanticLabel: 'Actions', + width: const FixedColumnWidth(72), + alignment: RemixDataTableCellAlignment.end, + cellBuilder: (context, row) => FortalIconButton.ghost( + icon: Icons.more_horiz, + semanticLabel: 'Actions for $row', + onPressed: () {}, + ), + ), + ], + ); + } +} +``` + + +## Localization and direction + +Remix never reads `MaterialLocalizations`. `labels` carries every built-in +string the table displays or announces, and `pageRangeFormatter` builds the +visible range, so an application can translate the component and reorder the +range without a Material host. `start` and `end` alignments follow +`Directionality`, and the previous and next chevrons mirror in RTL. + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class LocalizedTableExample extends StatelessWidget { + const LocalizedTableExample({super.key, required this.rows}); + + final List rows; + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.rtl, + child: FortalDataTable( + rows: rows, + labels: const RemixDataTableLabels( + rowsPerPage: 'Linhas por página', + previousPage: 'Página anterior', + nextPage: 'Próxima página', + selectAllRows: 'Selecionar todas as linhas desta página', + selectRow: 'Selecionar linha', + sortedAscending: 'ordenado de forma crescente', + sortedDescending: 'ordenado de forma decrescente', + ), + pageRangeFormatter: ({ + required int start, + required int end, + required int total, + }) => '$start–$end de $total', + totalRows: 42, + onPageChanged: (_) {}, + onPageSizeChanged: (_) {}, + columns: [ + RemixDataTableColumn( + id: 'value', + label: 'Valor', + cellBuilder: (context, row) => Text(row), + ), + ], + ), + ); + } +} +``` + + +## Width and scrolling + +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. + +## Accessibility + +- One `table` node carries `semanticLabel`; its children are `row` nodes whose + children are `columnHeader` and `cell` nodes. +- A sortable header is announced once as a button with its current sort state. +- Selection checkboxes keep their native checkbox semantics exactly once, and + select-all is false, true, or mixed for none, all, or some visible rows. +- Interactive cell content keeps its own actions. +- Pagination controls sit outside the structural table node, because + Flutter's role validation only admits `row` children under a `table`. + +## Fortal recipe + +`FortalDataTable` maps Radix Themes Table exactly for the passive visuals: + +| Size | Cell padding | Minimum row height | Typography | Radius | +| --- | --- | --- | --- | --- | +| `size1` | `space2` | 36 × scaling | `text2` | `radius3` | +| `size2` | `space3` | 44 × scaling | `text2` | `radius4` | +| `size3` | `space3` / `space4` | `space8` | `text3` | `radius4` | + +`surface` adds the panel background, the blended `gray-a5`/`gray-6` border, +the `gray-a2` header row, a clipped radius, and no divider under the last row. +`ghost` keeps a transparent surface and every divider. + +Sorting, selection, pagination, row hover, and horizontal scrolling have no +Radix counterpart — Radix's Table is a passive layout. They are recorded as +Fortal extensions in the parity manifest. + +## API Reference + +### RemixDataTable Properties + +#### `rows` → `List` + +Required. The rows of the current page, already sorted and sliced. + +#### `columns` → `List>` + +Required. The columns shared by the header and every row. Ids are unique. + +#### `semanticLabel` → `String?` + +Optional. Accessible name of the table itself. + +#### `sort` → `RemixDataTableSort?` / `onSortChanged` → `ValueChanged?` + +Optional. The active sort descriptor and the callback that receives the next +one. + +#### `rowId` → `Object Function(T row)?` / `selectedRowIds` → `Set` / `onSelectionChanged` → `ValueChanged>?` + +Optional. Row identity, the currently selected ids, and the callback that +receives a fresh unmodifiable set. `rowId` and `onSelectionChanged` are +supplied together or not at all. + +#### `totalRows` → `int?` / `pageIndex` → `int` / `pageSize` → `int` / `pageSizeOptions` → `List` + +Optional. Total row count across pages, the zero-based visible page, the page +size, and the sizes offered by the footer. + +#### `onPageChanged` → `ValueChanged?` / `onPageSizeChanged` → `ValueChanged?` + +Optional. Pagination callbacks. Supplied together with `totalRows` or not at +all. + +#### `minimumWidth` → `double` + +Optional. Lower bound of the laid-out table width before horizontal +scrolling. Defaults to `0`. + +#### `emptyBuilder` → `WidgetBuilder?` + +Optional. Replaces the body rows when `rows` is empty. + +#### `labels` → `RemixDataTableLabels` / `pageRangeFormatter` → `RemixDataTablePageRangeFormatter` + +Optional. Every built-in string and the visible page-range format. + +#### `sortableIcon` / `sortAscendingIcon` / `sortDescendingIcon` → `IconData` + +Optional. Sort indicators for an inactive, ascending, and descending column. + +#### `previousPageIcon` / `nextPageIcon` → `IconData` + +Optional. Pagination icons, mirrored in RTL. + +### RemixDataTableColumn Properties + +#### `id` → `String` + +Required. Stable identifier used by sort descriptors. Unique within a table. + +#### `cellBuilder` → `Widget Function(BuildContext context, T row)` + +Required. Builds this column's cell for one row. + +#### `label` → `String?` / `header` → `Widget?` + +Exactly one is required. `label` renders with the table's header typography; +`header` renders arbitrary content and additionally requires `semanticLabel`. + +#### `semanticLabel` → `String?` + +Accessible name of the column header. Defaults to `label`. + +#### `width` → `TableColumnWidth` + +Optional. Width shared by this column's header and body cells. Defaults to +`FlexColumnWidth()`. + +#### `alignment` → `RemixDataTableCellAlignment` + +Optional. Directional placement of this column's content. Defaults to `start`. + +#### `sortable` → `bool` + +Optional. Whether activating the header emits a new sort descriptor. + +### Style Methods + +#### `headerRow(BoxStyler value)` / `bodyRow(BoxStyler value)` / `lastBodyRow(BoxStyler value)` + +Sets the row chrome painted behind every cell of the header row, of a body +row, and of the final body row. `bodyRow` variants such as `onHovered` and +`onSelected` resolve against that row's own state. + +#### `headerCell(BoxStyler value)` / `bodyCell(BoxStyler value)` / `selectionCell(BoxStyler value)` + +Sets the inner cell boxes that own padding and per-cell decoration. + +#### `cellPadding(EdgeInsetsGeometryMix value)` + +Applies one padding to the header, body, and selection cells at once. + +#### `rowDivider(BorderSideMix value)` + +Draws one divider under the header and body rows. + +#### `headerLabel(TextStyler value)` / `cellText(TextStyler value)` / `footerLabel(TextStyler value)` + +Sets the header typography, the default typography inherited by cell content, +and the footer typography. + +#### `headerLabelTextStyle` / `headerLabelColor` / `footerLabelTextStyle` / `footerLabelColor` + +Convenience setters for the header and footer text style or color. + +#### `sortIcon(IconStyler value)` / `sortIconColor(Color color)` / `sortIconSpacing(double value)` + +Styles the sort indicator and the gap between it and its label. + +#### `footer(FlexBoxStyler value)` + +Styles the pagination footer, including its inter-control spacing. + +#### `selectionCheckbox(Style value)` / `pageButton(Style value)` / `pageSizeSelect(Style value)` + +Hands the composed controls an unresolved style, so each one still resolves +its own widget states. + +#### `headerMinHeight(double value)` / `rowMinHeight(double value)` / `selectionColumnWidth(double value)` + +Sets the header floor, the body row floor, and the width of the optional +selection column. + +#### `padding(EdgeInsetsGeometryMix value)` / `margin(EdgeInsetsGeometryMix value)` + +Sets outer container padding or margin. + +#### `color(Color value)` / `decoration(DecorationMix value)` / `border(BoxBorderMix value)` / `borderRadius(BorderRadiusGeometryMix value)` + +Sets the outer surface background, decoration, border, and radius. + +#### `wrap(WidgetModifierConfig value)` + +Applies widget modifiers such as clipping, opacity, or scaling. + +#### `animate(AnimationConfig value)` + +Configures implicit animation for style transitions. diff --git a/packages/dashboard/lib/pages/customers_page.dart b/packages/dashboard/lib/pages/customers_page.dart index c8dc184c..2fea7fea 100644 --- a/packages/dashboard/lib/pages/customers_page.dart +++ b/packages/dashboard/lib/pages/customers_page.dart @@ -3,8 +3,10 @@ import 'package:remix/remix.dart'; import '../data/customers.dart'; import '../data/models.dart'; +import '../utils/date_format.dart'; +import '../utils/pagination.dart'; import '../widgets/action_popover.dart'; -import '../widgets/data_grid.dart'; +import '../widgets/data_table_cell_text.dart'; import '../widgets/empty_state.dart'; import '../widgets/page_header.dart'; import '../widgets/toast.dart'; @@ -20,8 +22,11 @@ class CustomersPage extends StatefulWidget { class _CustomersPageState extends State { String _query = ''; - DataGridSort _sort = const DataGridSort('joined', .descending); - Set _selectedIds = {}; + RemixDataTableSort _sort = const RemixDataTableSort( + columnId: 'joined', + direction: .descending, + ); + Set _selectedIds = {}; int _page = 0; int _rowsPerPage = 10; @@ -34,12 +39,11 @@ class _CustomersPageState extends State { return haystack.contains(_query) && haystack.contains(globalQuery); }).toList(); filtered.sort(_compareCustomers); - final maxPage = filtered.isEmpty - ? 0 - : (filtered.length - 1) ~/ _rowsPerPage; - final safePage = _page.clamp(0, maxPage); - final start = safePage * _rowsPerPage; - final visible = filtered.skip(start).take(_rowsPerPage).toList(); + final (page: safePage, items: visible) = paginate( + filtered, + page: _page, + rowsPerPage: _rowsPerPage, + ); return SingleChildScrollView( padding: const EdgeInsets.all(32), @@ -113,23 +117,26 @@ class _CustomersPageState extends State { return Row(children: [search, const Spacer(), ?selection]); }, ), - DataGrid( + FortalDataTable.surface( key: const ValueKey('data-grid-customers'), rows: visible, columns: _columns, + semanticLabel: 'Customers', + minimumWidth: 840, sort: _sort, onSortChanged: (sort) => setState(() { _sort = sort; _page = 0; }), rowId: (customer) => customer.id, - selectedIds: _selectedIds, + selectedRowIds: _selectedIds, onSelectionChanged: (ids) => setState(() => _selectedIds = ids), totalRows: filtered.length, - page: safePage, - rowsPerPage: _rowsPerPage, + pageIndex: safePage, + pageSize: _rowsPerPage, + pageSizeOptions: const [5, 10, 20], onPageChanged: (page) => setState(() => _page = page), - onRowsPerPageChanged: (count) => setState(() { + onPageSizeChanged: (count) => setState(() { _rowsPerPage = count; _page = 0; }), @@ -152,63 +159,59 @@ class _CustomersPageState extends State { return _sort.direction == .ascending ? result : -result; } - List> get _columns => [ - DataGridColumn( + List> get _columns => [ + RemixDataTableColumn( id: 'name', label: 'Customer', sortable: true, - flex: 2, + width: const FlexColumnWidth(2), cellBuilder: (context, customer) => Row( mainAxisSize: .min, spacing: 9, children: [ FortalAvatar(size: .size2, label: customer.initials), - Flexible(child: _PrimaryText(customer.name)), + Flexible(child: DataTableCellText(customer.name, primary: true)), ], ), ), - DataGridColumn( + RemixDataTableColumn( id: 'email', label: 'Email', - flex: 2, - cellBuilder: (_, customer) => _SecondaryText(customer.email), + width: const FlexColumnWidth(2), + cellBuilder: (_, customer) => DataTableCellText(customer.email), ), - DataGridColumn( + RemixDataTableColumn( id: 'plan', label: 'Plan', - width: 110, - cellBuilder: (_, customer) => _PrimaryText(customer.plan), + width: const FixedColumnWidth(110), + cellBuilder: (_, customer) => + DataTableCellText(customer.plan, primary: true), ), - DataGridColumn( + RemixDataTableColumn( id: 'status', label: 'Status', - width: 110, + width: const FixedColumnWidth(110), cellBuilder: (_, customer) => _CustomerStatusBadge(customer.status), ), - DataGridColumn( + RemixDataTableColumn( id: 'joined', label: 'Joined', sortable: true, - width: 118, - cellBuilder: (_, customer) => _SecondaryText(_date(customer.joinedAt)), + width: const FixedColumnWidth(118), + cellBuilder: (_, customer) => + DataTableCellText(formatShortDate(customer.joinedAt)), ), - DataGridColumn( + RemixDataTableColumn( id: 'actions', - label: '', - width: 46, - align: .right, + header: const SizedBox.shrink(), + semanticLabel: 'Actions', + width: const FixedColumnWidth(64), + alignment: .end, cellBuilder: (context, customer) => DashboardActionPopover( key: ValueKey('customer-actions-${customer.id}'), semanticLabel: 'Actions for ${customer.name}', - positioning: const OverlayPositionConfig( - side: .bottom, - alignment: .end, - sideOffset: 4, - ), - trigger: const Padding( - padding: EdgeInsets.all(6), - child: Icon(Icons.more_horiz, size: 18), - ), + positioning: dataTableActionsPositioning, + trigger: dataTableActionsTrigger, actions: const [ DashboardAction(value: 'view', label: 'View profile'), DashboardAction(value: 'email', label: 'Send email'), @@ -243,49 +246,3 @@ class _CustomerStatusBadge extends StatelessWidget { ); } } - -class _PrimaryText extends StatelessWidget { - const _PrimaryText(this.text); - final String text; - - @override - Widget build(BuildContext context) => StyledText( - text, - style: TextStyler(style: FortalTokens.text2.mix()) - .fontWeight(.w500) - .color(FortalTokens.gray12()) - .maxLines(1) - .overflow(.ellipsis), - ); -} - -class _SecondaryText extends StatelessWidget { - const _SecondaryText(this.text); - final String text; - - @override - Widget build(BuildContext context) => StyledText( - text, - style: TextStyler( - style: FortalTokens.text2.mix(), - ).color(FortalTokens.gray11()).maxLines(1).overflow(.ellipsis), - ); -} - -String _date(DateTime value) { - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return '${months[value.month - 1]} ${value.day}, ${value.year}'; -} diff --git a/packages/dashboard/lib/pages/orders_page.dart b/packages/dashboard/lib/pages/orders_page.dart index 799c89c4..99977739 100644 --- a/packages/dashboard/lib/pages/orders_page.dart +++ b/packages/dashboard/lib/pages/orders_page.dart @@ -3,8 +3,10 @@ import 'package:remix/remix.dart'; import '../data/models.dart'; import '../data/orders.dart'; +import '../utils/date_format.dart'; +import '../utils/pagination.dart'; import '../widgets/action_popover.dart'; -import '../widgets/data_grid.dart'; +import '../widgets/data_table_cell_text.dart'; import '../widgets/empty_state.dart'; import '../widgets/page_header.dart'; import '../widgets/toast.dart'; @@ -22,7 +24,10 @@ class OrdersPage extends StatefulWidget { class _OrdersPageState extends State { _OrderFilter _filter = .all; - DataGridSort _sort = const DataGridSort('date', .descending); + RemixDataTableSort _sort = const RemixDataTableSort( + columnId: 'date', + direction: .descending, + ); int _page = 0; int _rowsPerPage = 10; @@ -37,14 +42,11 @@ class _OrdersPageState extends State { .contains(query); return statusMatches && queryMatches; }).toList()..sort(_compareOrders); - final maxPage = filtered.isEmpty - ? 0 - : (filtered.length - 1) ~/ _rowsPerPage; - final safePage = _page.clamp(0, maxPage); - final visible = filtered - .skip(safePage * _rowsPerPage) - .take(_rowsPerPage) - .toList(); + final (page: safePage, items: visible) = paginate( + filtered, + page: _page, + rowsPerPage: _rowsPerPage, + ); return SingleChildScrollView( padding: const EdgeInsets.all(32), @@ -87,20 +89,23 @@ class _OrdersPageState extends State { ), ), ), - DataGrid( + FortalDataTable.surface( key: const ValueKey('data-grid-orders'), rows: visible, columns: _columns, + semanticLabel: 'Orders', + minimumWidth: 840, sort: _sort, onSortChanged: (sort) => setState(() { _sort = sort; _page = 0; }), totalRows: filtered.length, - page: safePage, - rowsPerPage: _rowsPerPage, + pageIndex: safePage, + pageSize: _rowsPerPage, + pageSizeOptions: const [5, 10, 20], onPageChanged: (page) => setState(() => _page = page), - onRowsPerPageChanged: (count) => setState(() { + onPageSizeChanged: (count) => setState(() { _rowsPerPage = count; _page = 0; }), @@ -123,58 +128,55 @@ class _OrdersPageState extends State { return _sort.direction == .ascending ? result : -result; } - List> get _columns => [ - DataGridColumn( + List> get _columns => [ + RemixDataTableColumn( id: 'id', label: 'Order', - width: 120, - cellBuilder: (_, order) => _CellText(order.id, primary: true), + width: const FixedColumnWidth(120), + cellBuilder: (_, order) => DataTableCellText(order.id, primary: true), ), - DataGridColumn( + RemixDataTableColumn( id: 'customer', label: 'Customer', - flex: 2, - cellBuilder: (_, order) => _CellText(order.customer, primary: true), + width: const FlexColumnWidth(2), + cellBuilder: (_, order) => + DataTableCellText(order.customer, primary: true), ), - DataGridColumn( + RemixDataTableColumn( id: 'date', label: 'Date', - width: 120, + width: const FixedColumnWidth(120), sortable: true, - cellBuilder: (_, order) => _CellText(_date(order.date)), + cellBuilder: (_, order) => DataTableCellText(formatShortDate(order.date)), ), - DataGridColumn( + RemixDataTableColumn( id: 'amount', label: 'Amount', - width: 120, - align: .right, + width: const FixedColumnWidth(120), + alignment: .end, sortable: true, - cellBuilder: (_, order) => - _CellText('\$${order.amount.toStringAsFixed(2)}', primary: true), + cellBuilder: (_, order) => DataTableCellText( + '\$${order.amount.toStringAsFixed(2)}', + primary: true, + ), ), - DataGridColumn( + RemixDataTableColumn( id: 'status', label: 'Status', - width: 118, + width: const FixedColumnWidth(118), cellBuilder: (_, order) => _OrderStatusBadge(order.status), ), - DataGridColumn( + RemixDataTableColumn( id: 'actions', - label: '', - width: 46, - align: .right, + header: const SizedBox.shrink(), + semanticLabel: 'Actions', + width: const FixedColumnWidth(64), + alignment: .end, cellBuilder: (context, order) => DashboardActionPopover( key: ValueKey('order-actions-${order.id}'), semanticLabel: 'Actions for ${order.id}', - positioning: const OverlayPositionConfig( - side: .bottom, - alignment: .end, - sideOffset: 4, - ), - trigger: const Padding( - padding: EdgeInsets.all(6), - child: Icon(Icons.more_horiz, size: 18), - ), + positioning: dataTableActionsPositioning, + trigger: dataTableActionsTrigger, actions: const [ DashboardAction(value: 'view', label: 'View order'), DashboardAction(value: 'receipt', label: 'Download receipt'), @@ -211,37 +213,3 @@ class _OrderStatusBadge extends StatelessWidget { ); } } - -class _CellText extends StatelessWidget { - const _CellText(this.text, {this.primary = false}); - final String text; - final bool primary; - - @override - Widget build(BuildContext context) => StyledText( - text, - style: TextStyler(style: FortalTokens.text2.mix()) - .fontWeight(primary ? .w500 : .w400) - .color(primary ? FortalTokens.gray12() : FortalTokens.gray11()) - .maxLines(1) - .overflow(.ellipsis), - ); -} - -String _date(DateTime value) { - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return '${months[value.month - 1]} ${value.day}, ${value.year}'; -} diff --git a/packages/dashboard/lib/pages/overview_page.dart b/packages/dashboard/lib/pages/overview_page.dart index 1c57fa99..b72aaac4 100644 --- a/packages/dashboard/lib/pages/overview_page.dart +++ b/packages/dashboard/lib/pages/overview_page.dart @@ -4,7 +4,6 @@ import 'package:remix/remix.dart'; import '../data/activity.dart'; import '../data/models.dart'; import '../data/orders.dart'; -import '../widgets/data_grid.dart'; import '../widgets/page_header.dart'; import '../widgets/stat_card.dart'; @@ -210,36 +209,37 @@ class _RecentOrders extends StatelessWidget { ), ], ), - DataGrid( + FortalDataTable.surface( rows: orders.take(5).toList(), + semanticLabel: 'Recent orders', minimumWidth: 560, columns: [ - DataGridColumn( + RemixDataTableColumn( id: 'id', label: 'Order', - width: 105, + width: const FixedColumnWidth(105), cellBuilder: (_, order) => _OrderText(order.id, primary: true), ), - DataGridColumn( + RemixDataTableColumn( id: 'customer', label: 'Customer', - flex: 2, + width: const FlexColumnWidth(2), cellBuilder: (_, order) => _OrderText(order.customer), ), - DataGridColumn( + RemixDataTableColumn( id: 'amount', label: 'Amount', - width: 100, - align: .right, + width: const FixedColumnWidth(100), + alignment: .end, cellBuilder: (_, order) => _OrderText( '\$${order.amount.toStringAsFixed(2)}', primary: true, ), ), - DataGridColumn( + RemixDataTableColumn( id: 'status', label: 'Status', - width: 94, + width: const FixedColumnWidth(94), cellBuilder: (_, order) => FortalScope( accent: order.status == .paid ? .green : .amber, hasBackground: false, diff --git a/packages/dashboard/lib/utils/date_format.dart b/packages/dashboard/lib/utils/date_format.dart new file mode 100644 index 00000000..a9ebe03d --- /dev/null +++ b/packages/dashboard/lib/utils/date_format.dart @@ -0,0 +1,18 @@ +const _shortMonths = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/// Formats [value] as e.g. `Jan 5, 2026` for compact table cells. +String formatShortDate(DateTime value) => + '${_shortMonths[value.month - 1]} ${value.day}, ${value.year}'; diff --git a/packages/dashboard/lib/utils/pagination.dart b/packages/dashboard/lib/utils/pagination.dart new file mode 100644 index 00000000..c50012dd --- /dev/null +++ b/packages/dashboard/lib/utils/pagination.dart @@ -0,0 +1,19 @@ +/// Slices [items] into one page of [rowsPerPage] items. +/// +/// The returned page is [page] clamped to the last valid page, so a filter +/// that shrinks the result set never asks for a page past the end. +/// +/// [rowsPerPage] must be positive, matching `RemixDataTable.pageSize`'s own +/// precondition; a zero page size has no meaningful page count. +({int page, List items}) paginate( + List items, { + required int page, + required int rowsPerPage, +}) { + assert(rowsPerPage > 0, 'paginate requires a positive rowsPerPage.'); + final maxPage = items.isEmpty ? 0 : (items.length - 1) ~/ rowsPerPage; + final safePage = page.clamp(0, maxPage); + final visible = items.skip(safePage * rowsPerPage).take(rowsPerPage).toList(); + + return (page: safePage, items: visible); +} diff --git a/packages/dashboard/lib/widgets/action_popover.dart b/packages/dashboard/lib/widgets/action_popover.dart index 083f7b16..b99561b2 100644 --- a/packages/dashboard/lib/widgets/action_popover.dart +++ b/packages/dashboard/lib/widgets/action_popover.dart @@ -92,3 +92,19 @@ class _DashboardActionPopoverState extends State { ); } } + +/// Positioning for a [DashboardActionPopover] anchored to a data table's +/// trailing "actions" column: it expands from the row's end edge instead of +/// [DashboardActionPopover.positioning]'s default start edge, so the popover +/// never overshoots the table when the column sits at the table's own edge. +const dataTableActionsPositioning = OverlayPositionConfig( + side: OverlaySide.bottom, + alignment: OverlayAlignment.end, + sideOffset: 4, +); + +/// The compact kebab trigger shared by every data table actions column. +const dataTableActionsTrigger = Padding( + padding: EdgeInsets.all(6), + child: Icon(Icons.more_horiz, size: 18), +); diff --git a/packages/dashboard/lib/widgets/data_grid.dart b/packages/dashboard/lib/widgets/data_grid.dart deleted file mode 100644 index 540fbaf7..00000000 --- a/packages/dashboard/lib/widgets/data_grid.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:remix/remix.dart'; - -import 'empty_state.dart'; - -const _dataGridTargetExtent = 48.0; -const _dataGridRowExtent = _dataGridTargetExtent + 1; - -enum DataGridSortDirection { ascending, descending } - -class DataGridSort { - const DataGridSort(this.columnId, this.direction); - - final String columnId; - final DataGridSortDirection direction; -} - -class DataGridColumn { - const DataGridColumn({ - required this.id, - required this.label, - required this.cellBuilder, - this.width, - this.flex = 1, - this.align = .left, - this.sortable = false, - }) : assert(width != null || flex > 0); - - final String id; - final String label; - final Widget Function(BuildContext context, T row) cellBuilder; - final double? width; - final int flex; - final TextAlign align; - final bool sortable; -} - -class DataGrid extends StatelessWidget { - const DataGrid({ - super.key, - required this.rows, - required this.columns, - this.sort, - this.onSortChanged, - this.rowId, - this.selectedIds = const {}, - this.onSelectionChanged, - this.totalRows, - this.page = 0, - this.rowsPerPage = 10, - this.onPageChanged, - this.onRowsPerPageChanged, - this.minimumWidth = 840, - this.emptyBuilder, - }); - - final List rows; - final List> columns; - final DataGridSort? sort; - final ValueChanged? onSortChanged; - final String Function(T row)? rowId; - final Set selectedIds; - final ValueChanged>? onSelectionChanged; - final int? totalRows; - final int page; - final int rowsPerPage; - final ValueChanged? onPageChanged; - final ValueChanged? onRowsPerPageChanged; - final double minimumWidth; - final WidgetBuilder? emptyBuilder; - - bool get _selectable => rowId != null && onSelectionChanged != null; - - @override - Widget build(BuildContext context) { - final border = MixScope.tokenOf(FortalTokens.grayA5, context); - final radius = MixScope.tokenOf(FortalTokens.radius4, context); - return Container( - clipBehavior: .antiAlias, - decoration: BoxDecoration( - color: MixScope.tokenOf(FortalTokens.colorPanelSolid, context), - border: Border.all(color: border), - borderRadius: BorderRadius.all(radius), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final width = math.max(minimumWidth, constraints.maxWidth); - return SingleChildScrollView( - scrollDirection: .horizontal, - child: SizedBox( - width: width, - child: Column( - mainAxisSize: .min, - children: [ - _buildHeader(context), - if (rows.isEmpty) - (emptyBuilder?.call(context) ?? - const EmptyState( - icon: Icons.search_off_outlined, - title: 'No results found', - body: 'Try changing your filters or search query.', - )) - else - SizedBox( - // Reserve 48px content plus the row divider. - height: rows.length * _dataGridRowExtent, - child: ListView.builder( - physics: const NeverScrollableScrollPhysics(), - itemCount: rows.length, - itemBuilder: (context, index) => _DataGridRow( - row: rows[index], - columns: columns, - selectable: _selectable, - selected: - _selectable && - selectedIds.contains(rowId!(rows[index])), - onSelected: _selectable - ? (selected) => _toggleRow(rows[index], selected) - : null, - ), - ), - ), - if (totalRows != null) _buildFooter(context), - ], - ), - ), - ); - }, - ), - ); - } - - Widget _buildHeader(BuildContext context) { - final rowIds = rowId == null ? const [] : rows.map(rowId!).toList(); - final selectedOnPage = rowIds.where(selectedIds.contains).length; - final selectedValue = selectedOnPage == 0 - ? false - : selectedOnPage == rowIds.length - ? true - : null; - - return Container( - height: _dataGridRowExtent, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: MixScope.tokenOf(FortalTokens.grayA2, context), - border: Border( - bottom: BorderSide( - color: MixScope.tokenOf(FortalTokens.grayA6, context), - ), - ), - ), - child: Row( - children: [ - if (_selectable) - SizedBox( - width: _dataGridTargetExtent, - child: Center( - child: FortalCheckbox( - key: const ValueKey('grid-select-all'), - size: .size1, - selected: selectedValue, - tristate: true, - semanticLabel: 'Select all rows', - onChanged: (_) => _toggleAll( - select: selectedOnPage != rowIds.length, - rowIds: rowIds, - ), - ), - ), - ), - for (final column in columns) - _ColumnSlot( - width: column.width, - flex: column.flex, - align: column.align, - child: column.sortable - ? FortalButton.ghost( - key: ValueKey('sort-${column.id}'), - size: .size1, - onPressed: () => _sortBy(column.id), - label: column.label, - trailingIcon: sort?.columnId == column.id - ? sort!.direction == .ascending - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down - : Icons.unfold_more, - ) - : StyledText( - column.label, - style: TextStyler( - style: FortalTokens.text1.mix(), - ).fontWeight(.w600).color(FortalTokens.gray11()), - ), - ), - ], - ), - ); - } - - Widget _buildFooter(BuildContext context) { - final total = totalRows!; - final start = total == 0 ? 0 : page * rowsPerPage + 1; - final end = math.min((page + 1) * rowsPerPage, total); - final maxPage = total == 0 ? 0 : (total - 1) ~/ rowsPerPage; - return Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: MixScope.tokenOf(FortalTokens.grayA6, context), - ), - ), - ), - child: Row( - children: [ - StyledText( - 'Rows per page', - style: TextStyler( - style: FortalTokens.text1.mix(), - ).color(FortalTokens.gray11()), - ), - const SizedBox(width: 8), - FortalSelect( - size: .size1, - variant: .ghost, - trigger: const RemixSelectTrigger(placeholder: '10'), - items: const [ - RemixSelectItem(value: 5, label: '5'), - RemixSelectItem(value: 10, label: '10'), - RemixSelectItem(value: 20, label: '20'), - ], - selectedValue: rowsPerPage, - onChanged: (value) { - if (value != null) onRowsPerPageChanged?.call(value); - }, - ), - const Spacer(), - StyledText( - '$start–$end of $total', - style: TextStyler( - style: FortalTokens.text1.mix(), - ).color(FortalTokens.gray11()), - ), - const SizedBox(width: 10), - FortalIconButton.ghost( - key: const ValueKey('grid-previous'), - size: .size1, - semanticLabel: 'Previous page', - enabled: page > 0, - onPressed: () => onPageChanged?.call(page - 1), - icon: Icons.chevron_left, - ), - FortalIconButton.ghost( - key: const ValueKey('grid-next'), - size: .size1, - semanticLabel: 'Next page', - enabled: page < maxPage, - onPressed: () => onPageChanged?.call(page + 1), - icon: Icons.chevron_right, - ), - ], - ), - ); - } - - void _sortBy(String columnId) { - final direction = - sort?.columnId == columnId && - sort?.direction == DataGridSortDirection.ascending - ? DataGridSortDirection.descending - : DataGridSortDirection.ascending; - onSortChanged?.call(DataGridSort(columnId, direction)); - } - - void _toggleRow(T row, bool selected) { - final next = {...selectedIds}; - final id = rowId!(row); - selected ? next.add(id) : next.remove(id); - onSelectionChanged?.call(next); - } - - void _toggleAll({required bool select, required List rowIds}) { - final next = {...selectedIds}; - select ? next.addAll(rowIds) : next.removeAll(rowIds); - onSelectionChanged?.call(next); - } -} - -class _DataGridRow extends StatefulWidget { - const _DataGridRow({ - required this.row, - required this.columns, - required this.selectable, - required this.selected, - required this.onSelected, - }); - - final T row; - final List> columns; - final bool selectable; - final bool selected; - final ValueChanged? onSelected; - - @override - State<_DataGridRow> createState() => _DataGridRowState(); -} - -class _DataGridRowState extends State<_DataGridRow> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final background = widget.selected - ? MixScope.tokenOf( - _hovered ? FortalTokens.accentA4 : FortalTokens.accentA3, - context, - ) - : _hovered - ? MixScope.tokenOf(FortalTokens.grayA3, context) - : Colors.transparent; - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - height: _dataGridRowExtent, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: background, - border: Border( - bottom: BorderSide( - color: MixScope.tokenOf(FortalTokens.grayA5, context), - ), - ), - ), - child: Row( - children: [ - if (widget.selectable) - SizedBox( - width: _dataGridTargetExtent, - child: Center( - child: FortalCheckbox( - key: const ValueKey('grid-row-checkbox'), - size: .size1, - selected: widget.selected, - semanticLabel: 'Select row', - onChanged: (value) => - widget.onSelected?.call(value ?? false), - ), - ), - ), - for (final column in widget.columns) - _ColumnSlot( - width: column.width, - flex: column.flex, - align: column.align, - child: column.cellBuilder(context, widget.row), - ), - ], - ), - ), - ); - } -} - -class _ColumnSlot extends StatelessWidget { - const _ColumnSlot({ - required this.width, - required this.flex, - required this.align, - required this.child, - }); - - final double? width; - final int flex; - final TextAlign align; - final Widget child; - - @override - Widget build(BuildContext context) { - final content = Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Align( - alignment: align == .right - ? Alignment.centerRight - : Alignment.centerLeft, - child: child, - ), - ); - return width == null - ? Expanded(flex: flex, child: content) - : SizedBox(width: width, child: content); - } -} diff --git a/packages/dashboard/lib/widgets/data_table_cell_text.dart b/packages/dashboard/lib/widgets/data_table_cell_text.dart new file mode 100644 index 00000000..6999c3a0 --- /dev/null +++ b/packages/dashboard/lib/widgets/data_table_cell_text.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +/// Compact single-line cell text for a [RemixDataTable] body cell. +/// +/// [primary] switches between the emphasized (`gray-12`, medium weight) and +/// muted (`gray-11`, regular weight) roles shared by the dashboard's data +/// table pages. +class DataTableCellText extends StatelessWidget { + const DataTableCellText(this.text, {super.key, this.primary = false}); + + final String text; + final bool primary; + + @override + Widget build(BuildContext context) => StyledText( + text, + style: TextStyler(style: FortalTokens.text2.mix()) + .fontWeight(primary ? .w500 : .w400) + .color(primary ? FortalTokens.gray12() : FortalTokens.gray11()) + .maxLines(1) + .overflow(.ellipsis), + ); +} diff --git a/packages/dashboard/test/app_smoke_test.dart b/packages/dashboard/test/app_smoke_test.dart index 1054244e..94c476ee 100644 --- a/packages/dashboard/test/app_smoke_test.dart +++ b/packages/dashboard/test/app_smoke_test.dart @@ -153,7 +153,9 @@ void main() { await tester.tap(find.byKey(const ValueKey('nav-customers')).first); await tester.pump(); - expect(find.byKey(const ValueKey('data-grid-customers')), findsOneWidget); + // The generated Fortal wrapper forwards the key to the Remix widget it + // builds, so the key matches both. + expect(find.byKey(const ValueKey('data-grid-customers')), findsWidgets); expect(find.text('1–10 of 24'), findsOneWidget); }); @@ -279,26 +281,30 @@ void main() { await tester.tap(find.byKey(const ValueKey('nav-customers')).first); await tester.pump(); - final sortName = find.byKey(const ValueKey('sort-name')).first; + final sortName = find.text('Customer').first; await tester.tap(sortName); await tester.pump(); await tester.tap(sortName); await tester.pump(); expect(find.text('Sofia Young'), findsOneWidget); - final selectAll = find.byKey(const ValueKey('grid-select-all')).first; + final selectAll = find + .byKey(const ValueKey('remix-data-table-select-all')) + .first; await tester.tap(selectAll); await tester.pump(); expect(find.text('10 selected'), findsOneWidget); - final next = find.byKey(const ValueKey('grid-next')).first; + final next = find + .byKey(const ValueKey('remix-data-table-next-page')) + .first; await tester.ensureVisible(next); await tester.tap(next); await tester.pump(); expect(find.text('11–20 of 24'), findsOneWidget); }); - testWidgets('grid checkboxes preserve a 14 square inside a 48 target', ( + testWidgets('grid checkboxes fill their cell around a 14 square', ( tester, ) async { tester.view.physicalSize = const Size(1400, 900); @@ -310,11 +316,17 @@ void main() { await tester.tap(find.byKey(const ValueKey('nav-customers')).first); await tester.pump(); - final selectAll = find.byKey(const ValueKey('grid-select-all')).first; - final rowCheckbox = find.byKey(const ValueKey('grid-row-checkbox')).first; - - expect(tester.getSize(selectAll), const Size.square(48)); - expect(tester.getSize(rowCheckbox), const Size.square(48)); + final selectAll = find + .byKey(const ValueKey('remix-data-table-select-all')) + .first; + // The header checkbox comes first, so the next one belongs to row one. + final rowCheckbox = find.byType(RemixCheckbox).at(1); + + // The interaction target is the selection cell: the 48px column by the + // Radix size-2 row height, rather than a fixed square that would inflate + // the row. + expect(tester.getSize(selectAll), const Size(48, 44)); + expect(tester.getSize(rowCheckbox), const Size(48, 44)); expect( tester.getSize( find diff --git a/packages/playground/lib/registry/component_registry.dart b/packages/playground/lib/registry/component_registry.dart index f6c222e8..6ba499b8 100644 --- a/packages/playground/lib/registry/component_registry.dart +++ b/packages/playground/lib/registry/component_registry.dart @@ -11,6 +11,7 @@ import 'entries/card_entry.dart'; import 'entries/checkbox_entry.dart'; import 'entries/checkbox_group_entry.dart'; import 'entries/data_list_entry.dart'; +import 'entries/data_table_entry.dart'; import 'entries/divider_entry.dart'; import 'entries/menu_entry.dart'; import 'entries/progress_entry.dart'; @@ -102,6 +103,10 @@ final Map components = { brightness: Theme.of(context).brightness, child: PreviewShell(child: buildDataListExample()), ), + 'data_table': (context) => FortalScope( + brightness: Theme.of(context).brightness, + child: PreviewShell(child: buildDataTableExample()), + ), 'divider': (context) => FortalScope( brightness: Theme.of(context).brightness, child: PreviewShell(child: buildDividerExample()), diff --git a/packages/playground/lib/registry/entries/data_table_entry.dart b/packages/playground/lib/registry/entries/data_table_entry.dart new file mode 100644 index 00000000..d744159e --- /dev/null +++ b/packages/playground/lib/registry/entries/data_table_entry.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../../widgets/comparison_view.dart'; + +class _Member { + const _Member(this.id, this.name, this.role, this.seats); + + final String id; + final String name; + final String role; + final int seats; +} + +const _members = [ + _Member('m1', 'Leo Farias', 'Owner', 12), + _Member('m2', 'Ada Lovelace', 'Engineer', 4), + _Member('m3', 'Grace Hopper', 'Engineer', 7), +]; + +Widget buildDataTableExample() => const _DataTableExample(); + +class _DataTableExample extends StatefulWidget { + const _DataTableExample(); + + @override + State<_DataTableExample> createState() => _DataTableExampleState(); +} + +class _DataTableExampleState extends State<_DataTableExample> { + RemixDataTableSort _sort = const RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.ascending, + ); + Set _selected = {'m2'}; + int _pageIndex = 0; + int _pageSize = 10; + + List<_Member> get _sorted { + final rows = List<_Member>.of(_members); + rows.sort((a, b) { + final result = switch (_sort.columnId) { + 'seats' => a.seats.compareTo(b.seats), + _ => a.name.compareTo(b.name), + }; + + return _sort.direction == RemixDataTableSortDirection.ascending + ? result + : -result; + }); + + return rows; + } + + List> get _columns => [ + RemixDataTableColumn( + id: 'name', + label: 'Member', + sortable: true, + width: const FlexColumnWidth(2), + cellBuilder: (context, row) => Text(row.name), + ), + RemixDataTableColumn( + id: 'role', + label: 'Role', + width: const FixedColumnWidth(120), + cellBuilder: (context, row) => FortalBadge(label: row.role), + ), + RemixDataTableColumn( + id: 'seats', + label: 'Seats', + sortable: true, + width: const FixedColumnWidth(96), + alignment: RemixDataTableCellAlignment.end, + cellBuilder: (context, row) => Text('${row.seats}'), + ), + ]; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 900, + child: ComparisonView( + remix: [ + // Sortable, selectable, and paginated: every operation stays a + // controlled signal owned by this widget's state. + SizedBox( + width: 460, + child: FortalDataTable<_Member>.surface( + semanticLabel: 'Workspace members', + rows: _sorted, + columns: _columns, + sort: _sort, + onSortChanged: (sort) => setState(() => _sort = sort), + rowId: (row) => row.id, + selectedRowIds: _selected, + onSelectionChanged: (ids) => setState(() => _selected = ids), + totalRows: _members.length, + pageIndex: _pageIndex, + pageSize: _pageSize, + pageSizeOptions: const [5, 10, 20], + onPageChanged: (index) => setState(() => _pageIndex = index), + onPageSizeChanged: (size) => setState(() { + _pageSize = size; + _pageIndex = 0; + }), + ), + ), + // Ghost keeps every divider and drops the panel surface. + SizedBox( + width: 460, + child: FortalDataTable<_Member>.ghost( + size: .size1, + semanticLabel: 'Compact members', + rows: _sorted, + columns: _columns, + ), + ), + // The empty state replaces body rows and keeps the header. + SizedBox( + width: 460, + child: FortalDataTable<_Member>.surface( + semanticLabel: 'No members', + rows: const [], + columns: _columns, + emptyBuilder: (context) => const Padding( + padding: EdgeInsets.all(24), + child: Text('No members match this filter'), + ), + ), + ), + // Directional alignment follows Directionality, not a locale guess. + SizedBox( + width: 460, + child: Directionality( + textDirection: TextDirection.rtl, + child: FortalDataTable<_Member>.surface( + semanticLabel: 'أعضاء', + rows: _sorted, + columns: _columns, + ), + ), + ), + ], + material: [ + SizedBox( + width: 380, + child: DataTable( + columns: const [ + DataColumn(label: Text('Member')), + DataColumn(label: Text('Role')), + DataColumn(label: Text('Seats'), numeric: true), + ], + rows: [ + for (final member in _members) + DataRow( + cells: [ + DataCell(Text(member.name)), + DataCell(Text(member.role)), + DataCell(Text('${member.seats}')), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/playground/lib/routes/all_components.dart b/packages/playground/lib/routes/all_components.dart index 0f2c0e4b..c31d0ba6 100644 --- a/packages/playground/lib/routes/all_components.dart +++ b/packages/playground/lib/routes/all_components.dart @@ -7,6 +7,7 @@ import '../registry/entries/card_entry.dart'; import '../registry/entries/callout_entry.dart'; import '../registry/entries/checkbox_entry.dart'; import '../registry/entries/data_list_entry.dart'; +import '../registry/entries/data_table_entry.dart'; import '../registry/entries/divider_entry.dart'; import '../registry/entries/progress_entry.dart'; import '../registry/entries/radio_entry.dart'; @@ -50,6 +51,7 @@ class AllComponentsPage extends StatelessWidget { _section('Callout', buildCalloutExample()), _section('Checkbox', buildCheckboxExample()), _section('Data List', buildDataListExample()), + _section('Data Table', buildDataTableExample()), _section('Divider', buildDividerExample()), _section('Progress', buildProgressExample()), _section('Radio', buildRadioExample()), diff --git a/packages/remix/README.md b/packages/remix/README.md index 2f4c968a..40305f2a 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -287,6 +287,8 @@ Remix provides a comprehensive set of production-ready components: - **Badge** - Status indicators and labels - **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 - **Spinner** - Loading states diff --git a/packages/remix/lib/remix.dart b/packages/remix/lib/remix.dart index 27a43f77..cffd3b5b 100644 --- a/packages/remix/lib/remix.dart +++ b/packages/remix/lib/remix.dart @@ -12,6 +12,7 @@ export 'src/components/icon_button/icon_button.dart'; export 'src/components/card/card.dart'; export 'src/components/checkbox/checkbox.dart'; export 'src/components/data_list/data_list.dart'; +export 'src/components/data_table/data_table.dart'; export 'src/components/divider/divider.dart'; export 'src/components/menu/menu.dart'; export 'src/components/popover/popover.dart'; diff --git a/packages/remix/lib/src/components/data_table/data_table.dart b/packages/remix/lib/src/components/data_table/data_table.dart new file mode 100644 index 00000000..0f89837e --- /dev/null +++ b/packages/remix/lib/src/components/data_table/data_table.dart @@ -0,0 +1,25 @@ +library remix_data_table; + +import 'dart:math' as math; +import 'dart:ui' show SemanticsRole; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' + show RenderTable, SemanticsConfiguration; +import 'package:mix/mix.dart'; +import 'package:mix_annotations/mix_annotations.dart'; + +import '../../fortal/fortal.dart'; +import '../../rendering/remix_box_effects.dart'; +import '../../utilities/remix_style.dart'; +import '../../utilities/selected_mixin.dart'; +import '../checkbox/checkbox.dart'; +import '../icon_button/icon_button.dart'; +import '../select/select.dart'; + +part 'data_table_spec.dart'; +part 'data_table_style.dart'; +part 'data_table_widget.dart'; +part 'fortal_data_table_styles.dart'; +part 'data_table.g.dart'; diff --git a/packages/remix/lib/src/components/data_table/data_table.g.dart b/packages/remix/lib/src/components/data_table/data_table.g.dart new file mode 100644 index 00000000..e192735c --- /dev/null +++ b/packages/remix/lib/src/components/data_table/data_table.g.dart @@ -0,0 +1,1274 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_table.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$DataTableSpec implements Spec, Diagnosticable { + StyleSpec get container; + StyleSpec get headerRow; + StyleSpec get bodyRow; + StyleSpec? get lastBodyRow; + StyleSpec get headerCell; + StyleSpec get bodyCell; + StyleSpec get selectionCell; + StyleSpec get footer; + StyleSpec get headerLabel; + StyleSpec get cellText; + StyleSpec get footerLabel; + StyleSpec get sortIcon; + Style? get selectionCheckbox; + Style? get pageButton; + Style? get pageSizeSelect; + double? get headerMinHeight; + double? get rowMinHeight; + double? get selectionColumnWidth; + double? get sortIconSpacing; + RemixBoxEffectsSpec? get containerEffects; + + @override + Type get type => DataTableSpec; + + @override + DataTableSpec copyWith({ + StyleSpec? container, + StyleSpec? headerRow, + StyleSpec? bodyRow, + StyleSpec? lastBodyRow, + StyleSpec? headerCell, + StyleSpec? bodyCell, + StyleSpec? selectionCell, + StyleSpec? footer, + StyleSpec? headerLabel, + StyleSpec? cellText, + StyleSpec? footerLabel, + StyleSpec? sortIcon, + Style? selectionCheckbox, + Style? pageButton, + Style? pageSizeSelect, + double? headerMinHeight, + double? rowMinHeight, + double? selectionColumnWidth, + double? sortIconSpacing, + RemixBoxEffectsSpec? containerEffects, + }) { + return DataTableSpec( + container: container ?? this.container, + headerRow: headerRow ?? this.headerRow, + bodyRow: bodyRow ?? this.bodyRow, + lastBodyRow: lastBodyRow ?? this.lastBodyRow, + headerCell: headerCell ?? this.headerCell, + bodyCell: bodyCell ?? this.bodyCell, + selectionCell: selectionCell ?? this.selectionCell, + footer: footer ?? this.footer, + headerLabel: headerLabel ?? this.headerLabel, + cellText: cellText ?? this.cellText, + footerLabel: footerLabel ?? this.footerLabel, + sortIcon: sortIcon ?? this.sortIcon, + selectionCheckbox: selectionCheckbox ?? this.selectionCheckbox, + pageButton: pageButton ?? this.pageButton, + pageSizeSelect: pageSizeSelect ?? this.pageSizeSelect, + headerMinHeight: headerMinHeight ?? this.headerMinHeight, + rowMinHeight: rowMinHeight ?? this.rowMinHeight, + selectionColumnWidth: selectionColumnWidth ?? this.selectionColumnWidth, + sortIconSpacing: sortIconSpacing ?? this.sortIconSpacing, + containerEffects: containerEffects ?? this.containerEffects, + ); + } + + @override + DataTableSpec lerp(DataTableSpec? other, double t) { + return DataTableSpec( + container: container.lerp(other?.container, t), + headerRow: headerRow.lerp(other?.headerRow, t), + bodyRow: bodyRow.lerp(other?.bodyRow, t), + lastBodyRow: lastBodyRow?.lerp(other?.lastBodyRow, t), + headerCell: headerCell.lerp(other?.headerCell, t), + bodyCell: bodyCell.lerp(other?.bodyCell, t), + selectionCell: selectionCell.lerp(other?.selectionCell, t), + footer: footer.lerp(other?.footer, t), + headerLabel: headerLabel.lerp(other?.headerLabel, t), + cellText: cellText.lerp(other?.cellText, t), + footerLabel: footerLabel.lerp(other?.footerLabel, t), + sortIcon: sortIcon.lerp(other?.sortIcon, t), + selectionCheckbox: MixOps.lerpSnap( + selectionCheckbox, + other?.selectionCheckbox, + t, + ), + pageButton: MixOps.lerpSnap(pageButton, other?.pageButton, t), + pageSizeSelect: MixOps.lerpSnap(pageSizeSelect, other?.pageSizeSelect, t), + headerMinHeight: MixOps.lerp(headerMinHeight, other?.headerMinHeight, t), + rowMinHeight: MixOps.lerp(rowMinHeight, other?.rowMinHeight, t), + selectionColumnWidth: MixOps.lerp( + selectionColumnWidth, + other?.selectionColumnWidth, + t, + ), + sortIconSpacing: MixOps.lerp(sortIconSpacing, other?.sortIconSpacing, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), + ); + } + + @override + List get props => [ + container, + headerRow, + bodyRow, + lastBodyRow, + headerCell, + bodyCell, + selectionCell, + footer, + headerLabel, + cellText, + footerLabel, + sortIcon, + selectionCheckbox, + pageButton, + pageSizeSelect, + headerMinHeight, + rowMinHeight, + selectionColumnWidth, + sortIconSpacing, + containerEffects, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is DataTableSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('headerRow', headerRow)) + ..add(DiagnosticsProperty('bodyRow', bodyRow)) + ..add(DiagnosticsProperty('lastBodyRow', lastBodyRow)) + ..add(DiagnosticsProperty('headerCell', headerCell)) + ..add(DiagnosticsProperty('bodyCell', bodyCell)) + ..add(DiagnosticsProperty('selectionCell', selectionCell)) + ..add(DiagnosticsProperty('footer', footer)) + ..add(DiagnosticsProperty('headerLabel', headerLabel)) + ..add(DiagnosticsProperty('cellText', cellText)) + ..add(DiagnosticsProperty('footerLabel', footerLabel)) + ..add(DiagnosticsProperty('sortIcon', sortIcon)) + ..add(DiagnosticsProperty('selectionCheckbox', selectionCheckbox)) + ..add(DiagnosticsProperty('pageButton', pageButton)) + ..add(DiagnosticsProperty('pageSizeSelect', pageSizeSelect)) + ..add(DoubleProperty('headerMinHeight', headerMinHeight)) + ..add(DoubleProperty('rowMinHeight', rowMinHeight)) + ..add(DoubleProperty('selectionColumnWidth', selectionColumnWidth)) + ..add(DoubleProperty('sortIconSpacing', sortIconSpacing)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); + } +} + +@Deprecated( + 'Rename to `_\$DataTableSpec` and migrate the class declaration to `class DataTableSpec with _\$DataTableSpec`. The `_\$DataTableSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$DataTableSpecMethods = _$DataTableSpec; // ignore: unused_element + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal recipe for [RemixDataTable]. +/// +/// Sizes and variants map `@radix-ui/themes@3.3.0` `table.css` exactly: cell +/// padding, minimum cell height, typography, radius, the `gray-a5` row +/// divider, bold column headers, the surface panel/border, the `gray-a2` +/// header background, and the suppressed divider under a surface table's last +/// row. +/// +/// Sorting, selection, pagination, and row hover have no Radix counterpart — +/// Radix's Table is a passive layout. They are Fortal extensions built from +/// existing accent/gray control tokens and are recorded as extensions in the +/// parity manifest. +class FortalDataTable extends StatelessWidget { + const FortalDataTable({ + super.key, + this.size = .size2, + this.variant = .ghost, + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon = Icons.unfold_more, + this.sortAscendingIcon = Icons.keyboard_arrow_up, + this.sortDescendingIcon = Icons.keyboard_arrow_down, + this.previousPageIcon = Icons.chevron_left, + this.nextPageIcon = Icons.chevron_right, + }); + + const FortalDataTable.surface({ + super.key, + this.size = .size2, + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon = Icons.unfold_more, + this.sortAscendingIcon = Icons.keyboard_arrow_up, + this.sortDescendingIcon = Icons.keyboard_arrow_down, + this.previousPageIcon = Icons.chevron_left, + this.nextPageIcon = Icons.chevron_right, + }) : variant = FortalDataTableVariant.surface; + + const FortalDataTable.ghost({ + super.key, + this.size = .size2, + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon = Icons.unfold_more, + this.sortAscendingIcon = Icons.keyboard_arrow_up, + this.sortDescendingIcon = Icons.keyboard_arrow_down, + this.previousPageIcon = Icons.chevron_left, + this.nextPageIcon = Icons.chevron_right, + }) : variant = FortalDataTableVariant.ghost; + + final FortalDataTableSize size; + + final FortalDataTableVariant variant; + + final List rows; + + final List> columns; + + final String? semanticLabel; + + final RemixDataTableSort? sort; + + final ValueChanged? onSortChanged; + + final Object Function(T row)? rowId; + + final Set selectedRowIds; + + final ValueChanged>? onSelectionChanged; + + final int? totalRows; + + final int pageIndex; + + final int pageSize; + + final List pageSizeOptions; + + final ValueChanged? onPageChanged; + + final ValueChanged? onPageSizeChanged; + + final double minimumWidth; + + final WidgetBuilder? emptyBuilder; + + final RemixDataTableLabels labels; + + final RemixDataTablePageRangeFormatter pageRangeFormatter; + + final IconData sortableIcon; + + final IconData sortAscendingIcon; + + final IconData sortDescendingIcon; + + final IconData previousPageIcon; + + final IconData nextPageIcon; + + @override + Widget build(BuildContext context) { + return RemixDataTable( + key: this.key, + style: fortalDataTableStyle(size: this.size, variant: this.variant), + rows: this.rows, + columns: this.columns, + semanticLabel: this.semanticLabel, + sort: this.sort, + onSortChanged: this.onSortChanged, + rowId: this.rowId, + selectedRowIds: this.selectedRowIds, + onSelectionChanged: this.onSelectionChanged, + totalRows: this.totalRows, + pageIndex: this.pageIndex, + pageSize: this.pageSize, + pageSizeOptions: this.pageSizeOptions, + onPageChanged: this.onPageChanged, + onPageSizeChanged: this.onPageSizeChanged, + minimumWidth: this.minimumWidth, + emptyBuilder: this.emptyBuilder, + labels: this.labels, + pageRangeFormatter: this.pageRangeFormatter, + sortableIcon: this.sortableIcon, + sortAscendingIcon: this.sortAscendingIcon, + sortDescendingIcon: this.sortDescendingIcon, + previousPageIcon: this.previousPageIcon, + nextPageIcon: this.nextPageIcon, + ); + } +} + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class DataTableStyler extends MixStyler + with RemixBoxStylerMixin { + final Prop>? $container; + final Prop>? $headerRow; + final Prop>? $bodyRow; + final Prop>? $lastBodyRow; + final Prop>? $headerCell; + final Prop>? $bodyCell; + final Prop>? $selectionCell; + final Prop>? $footer; + final Prop>? $headerLabel; + final Prop>? $cellText; + final Prop>? $footerLabel; + final Prop>? $sortIcon; + final Prop>? $selectionCheckbox; + final Prop>? $pageButton; + final Prop>? $pageSizeSelect; + final Prop? $headerMinHeight; + final Prop? $rowMinHeight; + final Prop? $selectionColumnWidth; + final Prop? $sortIconSpacing; + final Prop? $containerEffects; + + const DataTableStyler.create({ + Prop>? container, + Prop>? headerRow, + Prop>? bodyRow, + Prop>? lastBodyRow, + Prop>? headerCell, + Prop>? bodyCell, + Prop>? selectionCell, + Prop>? footer, + Prop>? headerLabel, + Prop>? cellText, + Prop>? footerLabel, + Prop>? sortIcon, + Prop>? selectionCheckbox, + Prop>? pageButton, + Prop>? pageSizeSelect, + Prop? headerMinHeight, + Prop? rowMinHeight, + Prop? selectionColumnWidth, + Prop? sortIconSpacing, + Prop? containerEffects, + super.variants, + super.modifier, + super.animation, + }) : $container = container, + $headerRow = headerRow, + $bodyRow = bodyRow, + $lastBodyRow = lastBodyRow, + $headerCell = headerCell, + $bodyCell = bodyCell, + $selectionCell = selectionCell, + $footer = footer, + $headerLabel = headerLabel, + $cellText = cellText, + $footerLabel = footerLabel, + $sortIcon = sortIcon, + $selectionCheckbox = selectionCheckbox, + $pageButton = pageButton, + $pageSizeSelect = pageSizeSelect, + $headerMinHeight = headerMinHeight, + $rowMinHeight = rowMinHeight, + $selectionColumnWidth = selectionColumnWidth, + $sortIconSpacing = sortIconSpacing, + $containerEffects = containerEffects; + + DataTableStyler({ + BoxStyler? container, + BoxStyler? headerRow, + BoxStyler? bodyRow, + BoxStyler? lastBodyRow, + BoxStyler? headerCell, + BoxStyler? bodyCell, + BoxStyler? selectionCell, + FlexBoxStyler? footer, + TextStyler? headerLabel, + TextStyler? cellText, + TextStyler? footerLabel, + IconStyler? sortIcon, + Style? selectionCheckbox, + Style? pageButton, + Style? pageSizeSelect, + double? headerMinHeight, + double? rowMinHeight, + double? selectionColumnWidth, + double? sortIconSpacing, + RemixBoxEffectsMix? containerEffects, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + container: Prop.maybeMix(container), + headerRow: Prop.maybeMix(headerRow), + bodyRow: Prop.maybeMix(bodyRow), + lastBodyRow: Prop.maybeMix(lastBodyRow), + headerCell: Prop.maybeMix(headerCell), + bodyCell: Prop.maybeMix(bodyCell), + selectionCell: Prop.maybeMix(selectionCell), + footer: Prop.maybeMix(footer), + headerLabel: Prop.maybeMix(headerLabel), + cellText: Prop.maybeMix(cellText), + footerLabel: Prop.maybeMix(footerLabel), + sortIcon: Prop.maybeMix(sortIcon), + selectionCheckbox: Prop.maybe(selectionCheckbox), + pageButton: Prop.maybe(pageButton), + pageSizeSelect: Prop.maybe(pageSizeSelect), + headerMinHeight: Prop.maybe(headerMinHeight), + rowMinHeight: Prop.maybe(rowMinHeight), + selectionColumnWidth: Prop.maybe(selectionColumnWidth), + sortIconSpacing: Prop.maybe(sortIconSpacing), + containerEffects: Prop.maybeMix(containerEffects), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory DataTableStyler.container(BoxStyler value) => + DataTableStyler().container(value); + factory DataTableStyler.headerRow(BoxStyler value) => + DataTableStyler().headerRow(value); + factory DataTableStyler.bodyRow(BoxStyler value) => + DataTableStyler().bodyRow(value); + factory DataTableStyler.lastBodyRow(BoxStyler value) => + DataTableStyler().lastBodyRow(value); + factory DataTableStyler.headerCell(BoxStyler value) => + DataTableStyler().headerCell(value); + factory DataTableStyler.bodyCell(BoxStyler value) => + DataTableStyler().bodyCell(value); + factory DataTableStyler.selectionCell(BoxStyler value) => + DataTableStyler().selectionCell(value); + factory DataTableStyler.footer(FlexBoxStyler value) => + DataTableStyler().footer(value); + factory DataTableStyler.headerLabel(TextStyler value) => + DataTableStyler().headerLabel(value); + factory DataTableStyler.cellText(TextStyler value) => + DataTableStyler().cellText(value); + factory DataTableStyler.footerLabel(TextStyler value) => + DataTableStyler().footerLabel(value); + factory DataTableStyler.sortIcon(IconStyler value) => + DataTableStyler().sortIcon(value); + factory DataTableStyler.selectionCheckbox(Style value) => + DataTableStyler().selectionCheckbox(value); + factory DataTableStyler.pageButton(Style value) => + DataTableStyler().pageButton(value); + factory DataTableStyler.pageSizeSelect(Style value) => + DataTableStyler().pageSizeSelect(value); + factory DataTableStyler.headerMinHeight(double value) => + DataTableStyler().headerMinHeight(value); + factory DataTableStyler.rowMinHeight(double value) => + DataTableStyler().rowMinHeight(value); + factory DataTableStyler.selectionColumnWidth(double value) => + DataTableStyler().selectionColumnWidth(value); + factory DataTableStyler.sortIconSpacing(double value) => + DataTableStyler().sortIconSpacing(value); + factory DataTableStyler.containerEffects(RemixBoxEffectsMix value) => + DataTableStyler().containerEffects(value); + factory DataTableStyler.alignment(AlignmentGeometry value) => + DataTableStyler().alignment(value); + factory DataTableStyler.padding(EdgeInsetsGeometryMix value) => + DataTableStyler().padding(value); + factory DataTableStyler.margin(EdgeInsetsGeometryMix value) => + DataTableStyler().margin(value); + factory DataTableStyler.constraints(BoxConstraintsMix value) => + DataTableStyler().constraints(value); + factory DataTableStyler.decoration(DecorationMix value) => + DataTableStyler().decoration(value); + factory DataTableStyler.foregroundDecoration(DecorationMix value) => + DataTableStyler().foregroundDecoration(value); + factory DataTableStyler.clipBehavior(Clip value) => + DataTableStyler().clipBehavior(value); + factory DataTableStyler.color(Color value) => DataTableStyler().color(value); + factory DataTableStyler.gradient(GradientMix value) => + DataTableStyler().gradient(value); + factory DataTableStyler.border(BoxBorderMix value) => + DataTableStyler().border(value); + factory DataTableStyler.borderRadius(BorderRadiusGeometryMix value) => + DataTableStyler().borderRadius(value); + factory DataTableStyler.elevation(ElevationShadow value) => + DataTableStyler().elevation(value); + factory DataTableStyler.shadow(BoxShadowMix value) => + DataTableStyler().shadow(value); + factory DataTableStyler.shadows(List value) => + DataTableStyler().shadows(value); + factory DataTableStyler.width(double value) => DataTableStyler().width(value); + factory DataTableStyler.height(double value) => + DataTableStyler().height(value); + factory DataTableStyler.size(double width, double height) => + DataTableStyler().size(width, height); + factory DataTableStyler.minWidth(double value) => + DataTableStyler().minWidth(value); + factory DataTableStyler.maxWidth(double value) => + DataTableStyler().maxWidth(value); + factory DataTableStyler.minHeight(double value) => + DataTableStyler().minHeight(value); + factory DataTableStyler.maxHeight(double value) => + DataTableStyler().maxHeight(value); + factory DataTableStyler.scale( + double scale, { + Alignment alignment = .center, + }) => DataTableStyler().scale(scale, alignment: alignment); + factory DataTableStyler.rotate( + double radians, { + Alignment alignment = .center, + }) => DataTableStyler().rotate(radians, alignment: alignment); + factory DataTableStyler.translate(double x, double y, [double z = 0.0]) => + DataTableStyler().translate(x, y, z); + factory DataTableStyler.skew(double skewX, double skewY) => + DataTableStyler().skew(skewX, skewY); + factory DataTableStyler.textStyle(TextStyler value) => + DataTableStyler().textStyle(value); + factory DataTableStyler.image(DecorationImageMix value) => + DataTableStyler().image(value); + factory DataTableStyler.shape(ShapeBorderMix value) => + DataTableStyler().shape(value); + factory DataTableStyler.backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => DataTableStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory DataTableStyler.backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => DataTableStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory DataTableStyler.backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => DataTableStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory DataTableStyler.linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => DataTableStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory DataTableStyler.radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => DataTableStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory DataTableStyler.sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => DataTableStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory DataTableStyler.foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => DataTableStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory DataTableStyler.foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => DataTableStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory DataTableStyler.foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => DataTableStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory DataTableStyler.transform( + Matrix4 value, { + Alignment alignment = .center, + }) => DataTableStyler().transform(value, alignment: alignment); + + DataTableStyler alignment(AlignmentGeometry value) { + return container(BoxStyler().alignment(value)); + } + + DataTableStyler padding(EdgeInsetsGeometryMix value) { + return container(BoxStyler().padding(value)); + } + + DataTableStyler margin(EdgeInsetsGeometryMix value) { + return container(BoxStyler().margin(value)); + } + + DataTableStyler constraints(BoxConstraintsMix value) { + return container(BoxStyler().constraints(value)); + } + + DataTableStyler decoration(DecorationMix value) { + return container(BoxStyler().decoration(value)); + } + + DataTableStyler foregroundDecoration(DecorationMix value) { + return container(BoxStyler().foregroundDecoration(value)); + } + + DataTableStyler clipBehavior(Clip value) { + return container(BoxStyler().clipBehavior(value)); + } + + DataTableStyler color(Color value) { + return container(BoxStyler().color(value)); + } + + DataTableStyler gradient(GradientMix value) { + return container(BoxStyler().gradient(value)); + } + + DataTableStyler border(BoxBorderMix value) { + return container(BoxStyler().border(value)); + } + + DataTableStyler borderRadius(BorderRadiusGeometryMix value) { + return container(BoxStyler().borderRadius(value)); + } + + DataTableStyler elevation(ElevationShadow value) { + return container(BoxStyler().elevation(value)); + } + + DataTableStyler shadow(BoxShadowMix value) { + return container(BoxStyler().shadow(value)); + } + + DataTableStyler shadows(List value) { + return container(BoxStyler().shadows(value)); + } + + DataTableStyler width(double value) { + return container(BoxStyler().width(value)); + } + + DataTableStyler height(double value) { + return container(BoxStyler().height(value)); + } + + DataTableStyler size(double width, double height) { + return container(BoxStyler().size(width, height)); + } + + DataTableStyler minWidth(double value) { + return container(BoxStyler().minWidth(value)); + } + + DataTableStyler maxWidth(double value) { + return container(BoxStyler().maxWidth(value)); + } + + DataTableStyler minHeight(double value) { + return container(BoxStyler().minHeight(value)); + } + + DataTableStyler maxHeight(double value) { + return container(BoxStyler().maxHeight(value)); + } + + DataTableStyler scale(double scale, {Alignment alignment = .center}) { + return container(BoxStyler().scale(scale, alignment: alignment)); + } + + DataTableStyler rotate(double radians, {Alignment alignment = .center}) { + return container(BoxStyler().rotate(radians, alignment: alignment)); + } + + DataTableStyler translate(double x, double y, [double z = 0.0]) { + return container(BoxStyler().translate(x, y, z)); + } + + DataTableStyler skew(double skewX, double skewY) { + return container(BoxStyler().skew(skewX, skewY)); + } + + DataTableStyler textStyle(TextStyler value) { + return container(BoxStyler().textStyle(value)); + } + + DataTableStyler image(DecorationImageMix value) { + return container(BoxStyler().image(value)); + } + + DataTableStyler shape(ShapeBorderMix value) { + return container(BoxStyler().shape(value)); + } + + DataTableStyler backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + DataTableStyler backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + DataTableStyler backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + DataTableStyler linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + DataTableStyler radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + DataTableStyler sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + DataTableStyler foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + DataTableStyler foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + DataTableStyler foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + DataTableStyler transform(Matrix4 value, {Alignment alignment = .center}) { + return container(BoxStyler().transform(value, alignment: alignment)); + } + + /// Sets the container. + DataTableStyler container(BoxStyler value) { + return merge(DataTableStyler(container: value)); + } + + /// Sets the headerRow. + DataTableStyler headerRow(BoxStyler value) { + return merge(DataTableStyler(headerRow: value)); + } + + /// Sets the bodyRow. + DataTableStyler bodyRow(BoxStyler value) { + return merge(DataTableStyler(bodyRow: value)); + } + + /// Sets the lastBodyRow. + DataTableStyler lastBodyRow(BoxStyler value) { + return merge(DataTableStyler(lastBodyRow: value)); + } + + /// Sets the headerCell. + DataTableStyler headerCell(BoxStyler value) { + return merge(DataTableStyler(headerCell: value)); + } + + /// Sets the bodyCell. + DataTableStyler bodyCell(BoxStyler value) { + return merge(DataTableStyler(bodyCell: value)); + } + + /// Sets the selectionCell. + DataTableStyler selectionCell(BoxStyler value) { + return merge(DataTableStyler(selectionCell: value)); + } + + /// Sets the footer. + DataTableStyler footer(FlexBoxStyler value) { + return merge(DataTableStyler(footer: value)); + } + + /// Sets the headerLabel. + DataTableStyler headerLabel(TextStyler value) { + return merge(DataTableStyler(headerLabel: value)); + } + + /// Sets the cellText. + DataTableStyler cellText(TextStyler value) { + return merge(DataTableStyler(cellText: value)); + } + + /// Sets the footerLabel. + DataTableStyler footerLabel(TextStyler value) { + return merge(DataTableStyler(footerLabel: value)); + } + + /// Sets the sortIcon. + DataTableStyler sortIcon(IconStyler value) { + return merge(DataTableStyler(sortIcon: value)); + } + + /// Sets the selectionCheckbox. + DataTableStyler selectionCheckbox(Style value) { + return merge(DataTableStyler(selectionCheckbox: value)); + } + + /// Sets the pageButton. + DataTableStyler pageButton(Style value) { + return merge(DataTableStyler(pageButton: value)); + } + + /// Sets the pageSizeSelect. + DataTableStyler pageSizeSelect(Style value) { + return merge(DataTableStyler(pageSizeSelect: value)); + } + + /// Sets the headerMinHeight. + DataTableStyler headerMinHeight(double value) { + return merge(DataTableStyler(headerMinHeight: value)); + } + + /// Sets the rowMinHeight. + DataTableStyler rowMinHeight(double value) { + return merge(DataTableStyler(rowMinHeight: value)); + } + + /// Sets the selectionColumnWidth. + DataTableStyler selectionColumnWidth(double value) { + return merge(DataTableStyler(selectionColumnWidth: value)); + } + + /// Sets the sortIconSpacing. + DataTableStyler sortIconSpacing(double value) { + return merge(DataTableStyler(sortIconSpacing: value)); + } + + /// Sets the containerEffects. + DataTableStyler containerEffects(RemixBoxEffectsMix value) { + return merge(DataTableStyler(containerEffects: value)); + } + + /// Sets the animation configuration. + @override + DataTableStyler animate(AnimationConfig value) { + return merge(DataTableStyler(animation: value)); + } + + /// Sets the style variants. + @override + DataTableStyler variants(List> value) { + return merge(DataTableStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + DataTableStyler wrap(WidgetModifierConfig value) { + return merge(DataTableStyler(modifier: value)); + } + + /// Sets the widget modifier. + DataTableStyler modifier(WidgetModifierConfig value) { + return merge(DataTableStyler(modifier: value)); + } + + /// Merges with another [DataTableStyler]. + @override + DataTableStyler merge(DataTableStyler? other) { + return DataTableStyler.create( + container: MixOps.merge($container, other?.$container), + headerRow: MixOps.merge($headerRow, other?.$headerRow), + bodyRow: MixOps.merge($bodyRow, other?.$bodyRow), + lastBodyRow: MixOps.merge($lastBodyRow, other?.$lastBodyRow), + headerCell: MixOps.merge($headerCell, other?.$headerCell), + bodyCell: MixOps.merge($bodyCell, other?.$bodyCell), + selectionCell: MixOps.merge($selectionCell, other?.$selectionCell), + footer: MixOps.merge($footer, other?.$footer), + headerLabel: MixOps.merge($headerLabel, other?.$headerLabel), + cellText: MixOps.merge($cellText, other?.$cellText), + footerLabel: MixOps.merge($footerLabel, other?.$footerLabel), + sortIcon: MixOps.merge($sortIcon, other?.$sortIcon), + selectionCheckbox: MixOps.merge( + $selectionCheckbox, + other?.$selectionCheckbox, + ), + pageButton: MixOps.merge($pageButton, other?.$pageButton), + pageSizeSelect: MixOps.merge($pageSizeSelect, other?.$pageSizeSelect), + headerMinHeight: MixOps.merge($headerMinHeight, other?.$headerMinHeight), + rowMinHeight: MixOps.merge($rowMinHeight, other?.$rowMinHeight), + selectionColumnWidth: MixOps.merge( + $selectionColumnWidth, + other?.$selectionColumnWidth, + ), + sortIconSpacing: MixOps.merge($sortIconSpacing, other?.$sortIconSpacing), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = DataTableSpec( + container: MixOps.resolve(context, $container), + headerRow: MixOps.resolve(context, $headerRow), + bodyRow: MixOps.resolve(context, $bodyRow), + lastBodyRow: MixOps.resolve(context, $lastBodyRow), + headerCell: MixOps.resolve(context, $headerCell), + bodyCell: MixOps.resolve(context, $bodyCell), + selectionCell: MixOps.resolve(context, $selectionCell), + footer: MixOps.resolve(context, $footer), + headerLabel: MixOps.resolve(context, $headerLabel), + cellText: MixOps.resolve(context, $cellText), + footerLabel: MixOps.resolve(context, $footerLabel), + sortIcon: MixOps.resolve(context, $sortIcon), + selectionCheckbox: MixOps.resolve(context, $selectionCheckbox), + pageButton: MixOps.resolve(context, $pageButton), + pageSizeSelect: MixOps.resolve(context, $pageSizeSelect), + headerMinHeight: MixOps.resolve(context, $headerMinHeight), + rowMinHeight: MixOps.resolve(context, $rowMinHeight), + selectionColumnWidth: MixOps.resolve(context, $selectionColumnWidth), + sortIconSpacing: MixOps.resolve(context, $sortIconSpacing), + containerEffects: MixOps.resolve(context, $containerEffects), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('headerRow', $headerRow)) + ..add(DiagnosticsProperty('bodyRow', $bodyRow)) + ..add(DiagnosticsProperty('lastBodyRow', $lastBodyRow)) + ..add(DiagnosticsProperty('headerCell', $headerCell)) + ..add(DiagnosticsProperty('bodyCell', $bodyCell)) + ..add(DiagnosticsProperty('selectionCell', $selectionCell)) + ..add(DiagnosticsProperty('footer', $footer)) + ..add(DiagnosticsProperty('headerLabel', $headerLabel)) + ..add(DiagnosticsProperty('cellText', $cellText)) + ..add(DiagnosticsProperty('footerLabel', $footerLabel)) + ..add(DiagnosticsProperty('sortIcon', $sortIcon)) + ..add(DiagnosticsProperty('selectionCheckbox', $selectionCheckbox)) + ..add(DiagnosticsProperty('pageButton', $pageButton)) + ..add(DiagnosticsProperty('pageSizeSelect', $pageSizeSelect)) + ..add(DiagnosticsProperty('headerMinHeight', $headerMinHeight)) + ..add(DiagnosticsProperty('rowMinHeight', $rowMinHeight)) + ..add(DiagnosticsProperty('selectionColumnWidth', $selectionColumnWidth)) + ..add(DiagnosticsProperty('sortIconSpacing', $sortIconSpacing)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); + } + + @override + List get props => [ + $container, + $headerRow, + $bodyRow, + $lastBodyRow, + $headerCell, + $bodyCell, + $selectionCell, + $footer, + $headerLabel, + $cellText, + $footerLabel, + $sortIcon, + $selectionCheckbox, + $pageButton, + $pageSizeSelect, + $headerMinHeight, + $rowMinHeight, + $selectionColumnWidth, + $sortIconSpacing, + $containerEffects, + $animation, + $modifier, + $variants, + ]; +} diff --git a/packages/remix/lib/src/components/data_table/data_table_spec.dart b/packages/remix/lib/src/components/data_table/data_table_spec.dart new file mode 100644 index 00000000..d4d69e36 --- /dev/null +++ b/packages/remix/lib/src/components/data_table/data_table_spec.dart @@ -0,0 +1,183 @@ +part of 'data_table.dart'; + +/// Resolved visual values for a [RemixDataTable]. +/// +/// ## Region model +/// +/// [headerRow] and [bodyRow] are row *visuals* that are applied to every cell +/// of a row rather than to a single row widget. Flutter's [Table] has no +/// widget between the table and its cells, and Radix models the same thing the +/// same way: `.rt-TableCell` carries `--table-row-background-color` and the +/// `inset 0 -1px` divider, not the ``. Painting per cell therefore matches +/// upstream and keeps the divider continuous across columns. +/// +/// [headerCell], [bodyCell], and [selectionCell] are the inner boxes that own +/// padding and per-cell decoration inside that row chrome. +/// +/// ## Widget states +/// +/// Row and cell regions are re-resolved inside each row's own widget-state +/// scope, so `onHovered` / `onSelected` / `onPressed` variants on [bodyRow], +/// [bodyCell], [selectionCell], [headerRow], [headerCell], [headerLabel], and +/// [sortIcon] evaluate against that row (or that sortable header) instead of +/// the table as a whole. That is why there is no separate `selectedBodyRow` +/// region: `bodyRow(BoxStyler().onSelected(...))` already expresses it, and it +/// additionally composes with hover, focus, and press. +/// +/// ## Composed controls +/// +/// [selectionCheckbox], [pageButton], and [pageSizeSelect] deliberately hold +/// *unresolved* styles, which the renderer hands to the composed control +/// through Mix's own `StyleProvider` inheritance. The checkbox, icon button, +/// and select own their interaction state machines, so their styles have to +/// resolve against their own widget states; resolving them here would freeze +/// them in the table's state and, for example, drop a checked checkbox's +/// `onSelected` appearance. Because they are plain values rather than `Mix` +/// values, assigning one replaces the previous style instead of merging. +/// +/// Geometry scalars stay null here and default to zero at render time; the +/// unopinionated Remix renderer carries no Radix metrics. +@MixableSpec(extraStylerMixins: [RemixBoxStylerMixin]) +class DataTableSpec with _$DataTableSpec { + /// Outer surface: panel background, border, radius, and clipping. + @override + @MixableField(forwardStyler: true) + final StyleSpec container; + + /// Layered fills, strokes, and backdrop blur painted with [container]. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + + /// Row chrome painted behind every header cell. + @override + final StyleSpec headerRow; + + /// Row chrome painted behind every body cell. + @override + final StyleSpec bodyRow; + + /// Overrides merged over [bodyRow] for the final body row. + /// + /// 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. + @override + final StyleSpec? lastBodyRow; + + /// Inner box of a header cell. + @override + final StyleSpec headerCell; + + /// Inner box of a body cell. + @override + final StyleSpec bodyCell; + + /// Inner box of the optional selection column's header and body cells. + @override + final StyleSpec selectionCell; + + /// Pagination footer container, including its inter-control spacing. + @override + final StyleSpec footer; + + /// Typography of built-in column header labels. + /// + /// Custom [RemixDataTableColumn.header] widgets inherit it as their default + /// text style without losing their own semantics or interaction. + @override + final StyleSpec headerLabel; + + /// Default typography inherited by caller-supplied cell content. + /// + /// Radix sets `color: var(--gray-12)` on the row and lets cell markup + /// cascade from it; this is the Flutter equivalent, so a plain `Text` in a + /// cell picks it up while a styled descendant still wins. + @override + final StyleSpec cellText; + + /// Typography of the footer's built-in labels and page range. + @override + final StyleSpec footerLabel; + + /// Sort direction indicator shown in sortable column headers. + @override + final StyleSpec sortIcon; + + /// Unresolved style inherited by the composed selection checkboxes. + @override + final Style? selectionCheckbox; + + /// Unresolved style inherited by the composed previous/next page buttons. + @override + final Style? pageButton; + + /// Unresolved style inherited by the composed page-size select. + @override + final Style? pageSizeSelect; + + /// Minimum height of the header row. + @override + final double? headerMinHeight; + + /// Minimum height of every body row. + @override + final double? rowMinHeight; + + /// Width of the optional leading selection column. + @override + final double? selectionColumnWidth; + + /// Gap between a header label and its sort indicator. + @override + final double? sortIconSpacing; + + const DataTableSpec({ + StyleSpec? container, + StyleSpec? headerRow, + StyleSpec? bodyRow, + this.lastBodyRow, + StyleSpec? headerCell, + StyleSpec? bodyCell, + StyleSpec? selectionCell, + StyleSpec? footer, + StyleSpec? headerLabel, + StyleSpec? cellText, + StyleSpec? footerLabel, + StyleSpec? sortIcon, + this.selectionCheckbox, + this.pageButton, + this.pageSizeSelect, + this.headerMinHeight, + this.rowMinHeight, + this.selectionColumnWidth, + this.sortIconSpacing, + this.containerEffects, + }) : container = container ?? const StyleSpec(spec: BoxSpec()), + headerRow = headerRow ?? const StyleSpec(spec: BoxSpec()), + bodyRow = bodyRow ?? const StyleSpec(spec: BoxSpec()), + headerCell = headerCell ?? const StyleSpec(spec: BoxSpec()), + bodyCell = bodyCell ?? const StyleSpec(spec: BoxSpec()), + selectionCell = selectionCell ?? const StyleSpec(spec: BoxSpec()), + footer = footer ?? const StyleSpec(spec: FlexBoxSpec()), + headerLabel = headerLabel ?? const StyleSpec(spec: TextSpec()), + cellText = cellText ?? const StyleSpec(spec: TextSpec()), + footerLabel = footerLabel ?? const StyleSpec(spec: TextSpec()), + sortIcon = sortIcon ?? const StyleSpec(spec: IconSpec()); + + // Deliberate: route effects through lerpNullable so shadows/blends animate; + // the generator's default snap-lerps unrecognized spec types. + @override + DataTableSpec lerp(DataTableSpec? other, double t) { + if (other == null) return this; + final generated = super.lerp(other, t); + + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } +} diff --git a/packages/remix/lib/src/components/data_table/data_table_style.dart b/packages/remix/lib/src/components/data_table/data_table_style.dart new file mode 100644 index 00000000..62972987 --- /dev/null +++ b/packages/remix/lib/src/components/data_table/data_table_style.dart @@ -0,0 +1,46 @@ +part of 'data_table.dart'; + +/// Style helpers for [RemixDataTable] typography and shared row chrome. +extension RemixDataTableStylerRemixHelpers on DataTableStyler { + /// Sets the header label text style. + DataTableStyler headerLabelTextStyle(TextStyleMix style) { + return headerLabel(TextStyler(style: style)); + } + + /// Sets the header label color. + DataTableStyler headerLabelColor(Color color) { + return headerLabel(TextStyler(style: TextStyleMix(color: color))); + } + + /// Sets the footer label text style. + DataTableStyler footerLabelTextStyle(TextStyleMix style) { + return footerLabel(TextStyler(style: style)); + } + + /// Sets the footer label color. + DataTableStyler footerLabelColor(Color color) { + return footerLabel(TextStyler(style: TextStyleMix(color: color))); + } + + /// Sets the sort indicator color. + DataTableStyler sortIconColor(Color color) { + return sortIcon(IconStyler(color: color)); + } + + /// Applies [value] to the header cell and both body cell regions at once. + /// + /// The selection column shares the body cells' padding by default; call + /// [DataTableStyler.selectionCell] afterwards to diverge. + DataTableStyler cellPadding(EdgeInsetsGeometryMix value) { + return headerCell(BoxStyler().padding(value)) + .bodyCell(BoxStyler().padding(value)) + .selectionCell(BoxStyler().padding(value)); + } + + /// Applies [value] as the divider drawn under header and body rows. + DataTableStyler rowDivider(BorderSideMix value) { + final divider = BoxStyler().border(BoxBorderMix.bottom(value)); + + return headerRow(divider).bodyRow(divider); + } +} diff --git a/packages/remix/lib/src/components/data_table/data_table_widget.dart b/packages/remix/lib/src/components/data_table/data_table_widget.dart new file mode 100644 index 00000000..4a55f812 --- /dev/null +++ b/packages/remix/lib/src/components/data_table/data_table_widget.dart @@ -0,0 +1,1212 @@ +part of 'data_table.dart'; + +/// Direction of a [RemixDataTable]'s single active sort. +enum RemixDataTableSortDirection { ascending, descending } + +/// Directional placement of a column's content inside its cells. +/// +/// [start] and [end] follow [Directionality]; numeric columns opt into [end] +/// explicitly rather than receiving a locale guess. +enum RemixDataTableCellAlignment { start, center, end } + +/// Formats the footer's visible page range. +/// +/// Receives the one-based [start] and [end] of the visible page (both zero +/// when the result set is empty) and the [total] row count. +typedef RemixDataTablePageRangeFormatter = + String Function({required int start, required int end, required int total}); + +/// Default English page-range summary, e.g. `1–10 of 42`. +String remixDefaultDataTablePageRangeFormatter({ + required int start, + required int end, + required int total, +}) => '$start–$end of $total'; + +/// The column and direction a [RemixDataTable] is currently sorted by. +/// +/// This is a controlled signal: the table emits a new descriptor when a +/// sortable header is activated and never reorders rows itself. +@immutable +final class RemixDataTableSort { + const RemixDataTableSort({required this.columnId, required this.direction}) + : assert(columnId != '', 'RemixDataTableSort.columnId must be nonempty.'); + + /// Identifier of the sorted [RemixDataTableColumn]. + final String columnId; + + /// Whether the column is sorted ascending or descending. + final RemixDataTableSortDirection direction; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RemixDataTableSort && + other.columnId == columnId && + other.direction == direction; + + @override + int get hashCode => Object.hash(columnId, direction); + + @override + String toString() => 'RemixDataTableSort($columnId, ${direction.name})'; +} + +/// One column shared by every row of a [RemixDataTable]. +/// +/// Exactly one of [label] and [header] must be provided; a custom [header] +/// additionally requires a [semanticLabel] because the header's semantics node +/// replaces the announcement of its visible content. +@immutable +final class RemixDataTableColumn { + const RemixDataTableColumn({ + required this.id, + required this.cellBuilder, + this.label, + this.header, + this.semanticLabel, + this.width = const FlexColumnWidth(), + this.alignment = RemixDataTableCellAlignment.start, + this.sortable = false, + }) : assert(id != '', 'RemixDataTableColumn.id must be nonempty.'), + assert( + (label == null) != (header == null), + 'Provide exactly one of label or header to RemixDataTableColumn.', + ), + assert( + label != '', + 'RemixDataTableColumn.label must be nonempty when provided.', + ), + assert( + semanticLabel != '', + 'RemixDataTableColumn.semanticLabel must be nonempty when provided.', + ), + assert( + header == null || semanticLabel != null, + 'RemixDataTableColumn.header requires a semanticLabel: the header ' + 'semantics node replaces the announcement of its visible content.', + ); + + /// Stable identifier used by sort descriptors. Unique within a table. + final String id; + + /// Header text rendered with the table's header typography. + final String? label; + + /// Custom header content rendered instead of [label]. + final Widget? header; + + /// Accessible name of the column header. + /// + /// Required with [header]; defaults to [label] otherwise. + final String? semanticLabel; + + /// Width of this column, shared by its header and body cells. + final TableColumnWidth width; + + /// Directional placement of this column's content. + final RemixDataTableCellAlignment alignment; + + /// Whether activating this column's header emits a new sort descriptor. + final bool sortable; + + /// Builds this column's cell for one row. + final Widget Function(BuildContext context, T row) cellBuilder; + + /// Accessible name announced for this column's header. + String get _semanticLabel => semanticLabel ?? label!; +} + +/// Every built-in English string a [RemixDataTable] can announce or display. +/// +/// Replacing this object keeps Remix independent of `MaterialLocalizations`; +/// combined with [RemixDataTable.pageRangeFormatter] it lets an application +/// localize the whole component without wrapping it in a Material host. +@immutable +final class RemixDataTableLabels { + const RemixDataTableLabels({ + this.rowsPerPage = 'Rows per page', + this.previousPage = 'Previous page', + this.nextPage = 'Next page', + this.selectAllRows = 'Select all rows on this page', + this.selectRow = 'Select row', + this.sortedAscending = 'sorted ascending', + this.sortedDescending = 'sorted descending', + }); + + /// Visible label preceding the page-size select. + final String rowsPerPage; + + /// Accessible name of the previous-page button. + final String previousPage; + + /// Accessible name of the next-page button. + final String nextPage; + + /// Accessible name of the header select-all checkbox. + final String selectAllRows; + + /// Accessible name of a body row's selection checkbox. + final String selectRow; + + /// Announced sort state of an ascending sortable header. + final String sortedAscending; + + /// Announced sort state of a descending sortable header. + final String sortedDescending; +} + +/// A controlled table that compares many records under shared column headers. +/// +/// The table renders one bounded page of [rows] using Flutter's core [Table], +/// so every row negotiates the same column widths and the native +/// table/row/cell semantics roles are produced by the framework itself. +/// +/// ## Controlled signals +/// +/// Sorting, selection, and pagination are signals, not behavior. Remix never +/// fetches, filters, sorts, or slices caller data: [rows] is already the +/// current page in its final order, and the table only reports the intent to +/// change sort, selection, or page. +/// +/// - Sorting is active when a column sets `sortable: true` and +/// [onSortChanged] is supplied. Activating the header cycles +/// ascending/descending and emits a new [RemixDataTableSort]. +/// - Selection is active when both [rowId] and [onSelectionChanged] are +/// supplied. Select-all is scoped to the visible page and preserves +/// selections made on other pages. +/// - Pagination is active when [totalRows], [onPageChanged], and +/// [onPageSizeChanged] are all supplied. +/// +/// ## Layout contract +/// +/// Horizontal scrolling belongs to this widget: under a bounded width the +/// 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. +/// +/// ## Example +/// +/// ```dart +/// RemixDataTable( +/// rows: page, +/// columns: [ +/// RemixDataTableColumn( +/// id: 'name', +/// label: 'Name', +/// sortable: true, +/// cellBuilder: (context, row) => Text(row.name), +/// ), +/// ], +/// sort: sort, +/// onSortChanged: (value) => setState(() => sort = value), +/// ) +/// ``` +/// +/// See also: +/// +/// - [RemixDataList], which describes *one* record as label/value metadata. +/// DataTable compares *many* records under shared column headers. +// Deliberate: Flutter's core `Table` is the layout and semantics authority +// here. It is the one primitive that negotiates a single column-width map +// across every row, and `RenderTable` already emits the table/row/cell role +// hierarchy that `SemanticsRole` validation requires. It lays out every row it +// is given, which is exactly right because callers own pagination and +// virtualization is out of scope. +class RemixDataTable extends StatelessWidget { + const RemixDataTable({ + super.key, + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon = Icons.unfold_more, + this.sortAscendingIcon = Icons.keyboard_arrow_up, + this.sortDescendingIcon = Icons.keyboard_arrow_down, + this.previousPageIcon = Icons.chevron_left, + this.nextPageIcon = Icons.chevron_right, + this.style = const DataTableStyler.create(), + this.styleSpec, + }); + + static final styleFrom = DataTableStyler.new; + + /// The rows of the current page, already sorted and sliced by the caller. + final List rows; + + /// The columns shared by the header and every row. Ids are unique. + final List> columns; + + /// Optional accessible name for the table itself. + final String? semanticLabel; + + /// The active sort descriptor, or null when the table is unsorted. + final RemixDataTableSort? sort; + + /// Called with the next descriptor when a sortable header is activated. + final ValueChanged? onSortChanged; + + /// Derives a stable, value-equal identity for a row. + /// + /// Keyed by [Object] so callers choose any value-equal key without a second + /// type parameter. + final Object Function(T row)? rowId; + + /// Ids of the currently selected rows, including rows on other pages. + final Set selectedRowIds; + + /// Called with a fresh unmodifiable id set when selection changes. + final ValueChanged>? onSelectionChanged; + + /// Total number of rows across all pages. + final int? totalRows; + + /// Zero-based index of the visible page. + final int pageIndex; + + /// Number of rows per page. Must be one of [pageSizeOptions]. + final int pageSize; + + /// Page sizes offered by the footer's select. Unique and positive. + final List pageSizeOptions; + + /// Called with the next page index. + final ValueChanged? onPageChanged; + + /// Called with the next page size. + final ValueChanged? onPageSizeChanged; + + /// Lower bound of the laid-out table width before horizontal scrolling. + final double minimumWidth; + + /// Replaces the body rows when [rows] is empty. + /// + /// The column header and the optional pagination footer are preserved. + final WidgetBuilder? emptyBuilder; + + /// Every built-in string the table displays or announces. + final RemixDataTableLabels labels; + + /// Formats the footer's visible page range. + final RemixDataTablePageRangeFormatter pageRangeFormatter; + + /// Indicator shown on a sortable column that is not the active sort. + final IconData sortableIcon; + + /// Indicator shown on the ascending active sort column. + final IconData sortAscendingIcon; + + /// Indicator shown on the descending active sort column. + final IconData sortDescendingIcon; + + /// Icon of the previous-page button. Mirrored with [nextPageIcon] in RTL. + final IconData previousPageIcon; + + /// Icon of the next-page button. Mirrored with [previousPageIcon] in RTL. + final IconData nextPageIcon; + + /// The style configuration for the table. + final DataTableStyler style; + + /// Optional raw style spec that bypasses fluent style resolution. + final DataTableSpec? styleSpec; + + bool get _selectable => rowId != null && onSelectionChanged != null; + + bool get _paginated => + totalRows != null && onPageChanged != null && onPageSizeChanged != null; + + @override + Widget build(BuildContext context) { + // One immutable snapshot per build: the const constructor cannot copy, so + // this is where the "callers must not mutate during build" contract is + // enforced for everything rendered below. + final visibleRows = List.unmodifiable(rows); + final tableColumns = List>.unmodifiable(columns); + final selection = Set.unmodifiable(selectedRowIds); + final sizeOptions = List.unmodifiable(pageSizeOptions); + final rowIds = _resolveRowIds(visibleRows); + + assert(_debugValidateColumns(tableColumns)); + assert(_debugValidateSelection(rowIds)); + assert(_debugValidatePagination(sizeOptions)); + assert( + semanticLabel == null || semanticLabel!.trim().isNotEmpty, + 'RemixDataTable.semanticLabel must not be blank when provided: it ' + 'becomes the accessible name of the table.', + ); + assert( + minimumWidth >= 0 && minimumWidth.isFinite, + 'RemixDataTable.minimumWidth must be a finite non-negative value.', + ); + + return RemixStyleSpecBuilder( + style: style, + styleSpec: styleSpec, + builder: (context, spec) => _RemixDataTableView( + table: this, + rows: visibleRows, + columns: tableColumns, + rowIds: rowIds, + selectedRowIds: selection, + pageSizeOptions: sizeOptions, + // 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, + spec: spec, + ), + ), + ); + } + + /// Row identities for the visible page, or null when selection is disabled. + List? _resolveRowIds(List visibleRows) { + final resolve = rowId; + if (!_selectable || resolve == null) return null; + + return List.unmodifiable(visibleRows.map(resolve)); + } + + bool _debugValidateColumns(List> tableColumns) { + assert( + tableColumns.isNotEmpty, + 'RemixDataTable.columns must not be empty.', + ); + final ids = {}; + for (final column in tableColumns) { + assert( + column.id.trim().isNotEmpty, + 'RemixDataTableColumn.id must not be blank.', + ); + assert( + ids.add(column.id), + 'RemixDataTable.columns contains duplicate id "${column.id}".', + ); + assert( + column._semanticLabel.trim().isNotEmpty, + 'RemixDataTableColumn "${column.id}" must expose a nonblank ' + 'accessible header name.', + ); + assert( + !column.sortable || onSortChanged != null, + 'RemixDataTableColumn "${column.id}" is sortable but ' + 'RemixDataTable.onSortChanged is null, so activating its header ' + 'could not report anything.', + ); + } + final descriptor = sort; + if (descriptor != null) { + final target = tableColumns + .where((column) => column.id == descriptor.columnId) + .firstOrNull; + assert( + target != null, + 'RemixDataTable.sort names column "${descriptor.columnId}", which is ' + 'not one of the supplied columns.', + ); + assert( + target == null || target.sortable, + 'RemixDataTable.sort names column "${descriptor.columnId}", which is ' + 'not sortable.', + ); + } + + return true; + } + + bool _debugValidateSelection(List? rowIds) { + assert( + (rowId == null) == (onSelectionChanged == null), + 'RemixDataTable selection requires both rowId and onSelectionChanged, ' + 'or neither. Supplying one alone cannot produce partial behavior.', + ); + if (rowIds == null) return true; + final seen = {}; + for (final id in rowIds) { + assert( + id != '', + 'RemixDataTable.rowId must not return an empty identity.', + ); + assert( + seen.add(id), + 'RemixDataTable.rowId produced duplicate identity "$id" on the ' + 'visible page.', + ); + } + + return true; + } + + bool _debugValidatePagination(List sizeOptions) { + final total = totalRows; + assert( + (total == null) == (onPageChanged == null) && + (total == null) == (onPageSizeChanged == null), + 'RemixDataTable pagination requires totalRows, onPageChanged, and ' + 'onPageSizeChanged together, or none of them.', + ); + if (total == null) return true; + assert(total >= 0, 'RemixDataTable.totalRows must not be negative.'); + assert(pageSize > 0, 'RemixDataTable.pageSize must be positive.'); + assert( + sizeOptions.isNotEmpty && sizeOptions.every((option) => option > 0), + 'RemixDataTable.pageSizeOptions must be nonempty and positive.', + ); + assert( + sizeOptions.toSet().length == sizeOptions.length, + 'RemixDataTable.pageSizeOptions must not contain duplicates.', + ); + assert( + sizeOptions.contains(pageSize), + 'RemixDataTable.pageSize ($pageSize) must be one of pageSizeOptions ' + '($sizeOptions).', + ); + assert(pageIndex >= 0, 'RemixDataTable.pageIndex must not be negative.'); + assert( + pageIndex == 0 || pageIndex <= (total - 1) ~/ pageSize, + 'RemixDataTable.pageIndex ($pageIndex) is past the last page for ' + '$total rows at $pageSize per page.', + ); + + return true; + } +} + +/// Style inputs for one table: the resolved table-level spec plus the styler +/// that per-row regions re-resolve against their own widget states. +@immutable +class _DataTableStyles { + const _DataTableStyles({required this.styler, required this.spec}); + + /// Null when the caller supplied a raw [DataTableSpec], which carries no + /// variants to re-resolve. + final DataTableStyler? styler; + + /// Values resolved once, outside any row's widget-state scope. + final DataTableSpec spec; + + /// Resolves [prop] in [context] so widget-state variants see the row's + /// states, falling back to the table-level value when there is no styler. + V _resolve(BuildContext context, Prop? prop, V fallback) { + if (styler == null || prop == null) return fallback; + + return MixOps.resolve(context, prop) ?? fallback; + } + + StyleSpec headerRow(BuildContext context) => + _resolve(context, styler?.$headerRow, spec.headerRow); + + /// The final row merges [DataTableSpec.lastBodyRow] over the shared row + /// chrome; every other row uses the shared chrome alone. + StyleSpec bodyRow(BuildContext context, {required bool isLast}) { + if (!isLast) return _resolve(context, styler?.$bodyRow, spec.bodyRow); + + return _resolve( + context, + MixOps.merge(styler?.$bodyRow, styler?.$lastBodyRow), + spec.lastBodyRow ?? spec.bodyRow, + ); + } + + StyleSpec headerCell(BuildContext context) => + _resolve(context, styler?.$headerCell, spec.headerCell); + + StyleSpec bodyCell(BuildContext context) => + _resolve(context, styler?.$bodyCell, spec.bodyCell); + + StyleSpec selectionCell(BuildContext context) => + _resolve(context, styler?.$selectionCell, spec.selectionCell); + + StyleSpec headerLabel(BuildContext context) => + _resolve(context, styler?.$headerLabel, spec.headerLabel); + + StyleSpec cellText(BuildContext context) => + _resolve(context, styler?.$cellText, spec.cellText); + + StyleSpec sortIcon(BuildContext context) => + _resolve(context, styler?.$sortIcon, spec.sortIcon); +} + +/// Renders one table: header, body, optional empty surface, optional footer. +/// +/// Stateful because row hover is shared by every cell of a row. Flutter's +/// [Table] has no per-row widget to host a [MouseRegion], so each cell reports +/// its row index here and the whole body repaints with the new row states. +class _RemixDataTableView extends StatefulWidget { + const _RemixDataTableView({ + required this.table, + required this.rows, + required this.columns, + required this.rowIds, + required this.selectedRowIds, + required this.pageSizeOptions, + required this.styles, + }); + + /// The originating widget, consulted for its scalar fields only (sort, + /// callbacks, labels, icons, pagination indices). Its [RemixDataTable.rows], + /// [RemixDataTable.columns], [RemixDataTable.selectedRowIds], and + /// [RemixDataTable.pageSizeOptions] are shadowed on purpose by the + /// pre-normalized fields below: [RemixDataTable.build] already paid for the + /// defensive `List.unmodifiable` copy once, so reading through [table] here + /// instead would repeat that copy on every hover-only rebuild of this + /// State, rather than once per rebuild of the whole table. + final RemixDataTable table; + final List rows; + final List> columns; + final List? rowIds; + final Set selectedRowIds; + final List pageSizeOptions; + final _DataTableStyles styles; + + @override + State<_RemixDataTableView> createState() => _RemixDataTableViewState(); +} + +class _RemixDataTableViewState extends State<_RemixDataTableView> { + int? _hoveredRow; + + RemixDataTable get _table => widget.table; + + bool get _selectable => _table._selectable; + + DataTableSpec get _spec => widget.styles.spec; + + void _setHoveredRow(int index, bool hovered) { + final next = hovered ? index : (_hoveredRow == index ? null : _hoveredRow); + if (next == _hoveredRow) return; + setState(() => _hoveredRow = next); + } + + void _sortBy(RemixDataTableColumn column) { + final current = _table.sort; + final ascending = + current == null || + current.columnId != column.id || + current.direction == RemixDataTableSortDirection.descending; + _table.onSortChanged?.call( + RemixDataTableSort( + columnId: column.id, + direction: ascending + ? RemixDataTableSortDirection.ascending + : RemixDataTableSortDirection.descending, + ), + ); + } + + void _emitSelection(Set next) { + _table.onSelectionChanged?.call(Set.unmodifiable(next)); + } + + void _toggleRow(Object id, bool selected) { + final next = {...widget.selectedRowIds}; + if (selected) { + next.add(id); + } else { + next.remove(id); + } + _emitSelection(next); + } + + /// Select-all is page scoped: it only adds or removes the visible ids and + /// leaves selections made on other pages untouched. + void _toggleAll(bool select) { + final ids = widget.rowIds ?? const []; + final next = {...widget.selectedRowIds}; + if (select) { + next.addAll(ids); + } else { + next.removeAll(ids); + } + _emitSelection(next); + } + + /// False, true, or null for none, all, or some of the visible ids selected. + bool? get _selectAllValue { + final ids = widget.rowIds ?? const []; + if (ids.isEmpty) return false; + final selected = ids.where(widget.selectedRowIds.contains).length; + if (selected == 0) return false; + + return selected == ids.length ? true : null; + } + + Map get _columnWidths { + final offset = _selectable ? 1 : 0; + + return { + if (_selectable) + 0: FixedColumnWidth( + _spec.selectionColumnWidth ?? _defaultSelectionExtent, + ), + for (var index = 0; index < widget.columns.length; index += 1) + index + offset: widget.columns[index].width, + }; + } + + @override + Widget build(BuildContext context) { + assert( + (_spec.selectionColumnWidth ?? 0.0) >= 0 && + (_spec.headerMinHeight ?? 0.0) >= 0 && + (_spec.rowMinHeight ?? 0.0) >= 0 && + (_spec.sortIconSpacing ?? 0.0) >= 0, + 'DataTableSpec dimensions must resolve to non-negative values.', + ); + + final content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTable(context), + // Flutter's Table cannot span a cell across columns and its role + // validation rejects a `row` outside a `table`, so the empty surface + // is rendered adjacent to the header instead of inside it. Its + // semantics stay caller-owned. + if (widget.rows.isEmpty && _table.emptyBuilder != null) + _table.emptyBuilder!(context), + if (_table._paginated) _buildFooter(context), + ], + ); + + return RemixBoxWithEffects( + styleSpec: _spec.container, + containerEffects: _spec.containerEffects, + child: LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxWidth; + if (!available.isFinite) { + // Nothing to scroll inside. IntrinsicWidth gives the column a + // finite width, so flex columns never resolve against unbounded + // constraints and the footer still spans the table; minimumWidth + // remains the flex target. + return IntrinsicWidth( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: _table.minimumWidth), + child: content, + ), + ); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: math.max(_table.minimumWidth, available), + child: content, + ), + ); + }, + ), + ); + } + + Widget _buildTable(BuildContext context) { + return _DataTableLayout( + semanticLabel: _table.semanticLabel, + columnWidths: _columnWidths, + textDirection: Directionality.of(context), + children: [ + _buildHeaderRow(context), + for (var index = 0; index < widget.rows.length; index += 1) + _buildBodyRow(context, index), + ], + ); + } + + TableRow _buildHeaderRow(BuildContext context) { + return TableRow( + children: [ + if (_selectable) + _DataTableHeaderCell( + styles: widget.styles, + minHeight: _spec.headerMinHeight, + alignment: RemixDataTableCellAlignment.center, + useSelectionCell: true, + child: _checkbox( + RemixCheckbox( + key: const ValueKey('remix-data-table-select-all'), + selected: _selectAllValue, + tristate: true, + semanticLabel: _table.labels.selectAllRows, + minimumTapTargetSize: _selectionTargetSize( + _spec.headerMinHeight, + ), + onChanged: (_) => _toggleAll(_selectAllValue != true), + ), + ), + ), + for (final column in widget.columns) + _DataTableHeaderCell( + styles: widget.styles, + minHeight: _spec.headerMinHeight, + semanticLabel: column._semanticLabel, + alignment: column.alignment, + sortState: _sortStateOf(column), + onSort: column.sortable ? () => _sortBy(column) : null, + sortValue: _sortValueOf(column), + child: column.header, + label: column.label, + sortIcons: _sortIcons, + ), + ], + ); + } + + TableRow _buildBodyRow(BuildContext context, int index) { + final row = widget.rows[index]; + final id = widget.rowIds?[index]; + final isLast = index == widget.rows.length - 1; + final selected = id != null && widget.selectedRowIds.contains(id); + final states = { + if (_hoveredRow == index) WidgetState.hovered, + if (selected) WidgetState.selected, + }; + + return TableRow( + key: id == null ? null : ValueKey(id), + children: [ + if (_selectable) + _DataTableBodyCell( + styles: widget.styles, + states: states, + isLastRow: isLast, + minHeight: _spec.rowMinHeight, + useSelectionCell: true, + onHoverChanged: (hovered) => _setHoveredRow(index, hovered), + child: _checkbox( + RemixCheckbox( + selected: selected, + semanticLabel: _table.labels.selectRow, + minimumTapTargetSize: _selectionTargetSize(_spec.rowMinHeight), + onChanged: (value) => _toggleRow(id!, value ?? false), + ), + ), + ), + for (final column in widget.columns) + _DataTableBodyCell( + styles: widget.styles, + states: states, + isLastRow: isLast, + minHeight: _spec.rowMinHeight, + alignment: column.alignment, + onHoverChanged: (hovered) => _setHoveredRow(index, hovered), + child: column.cellBuilder(context, row), + ), + ], + ); + } + + /// Sizes a selection checkbox to its own cell. + /// + /// [RemixCheckbox] would otherwise apply its hardcoded 48px minimum target, + /// which fights the table on both axes: it inflates Radix's 36px and 44px + /// rows the moment selection is enabled, and Flutter's [Table] lays every + /// cell out at a tight width, so a selection column narrower than 48 — + /// anything below 100% scaling — silently clamps the target back down + /// instead of honoring it. Matching the cell keeps the caller's row metric, + /// never gets clamped, and still gives an unstyled table a usable control. + Size _selectionTargetSize(double? minHeight) => Size( + _spec.selectionColumnWidth ?? _defaultSelectionExtent, + minHeight ?? _defaultSelectionExtent, + ); + + /// Hands a composed control its style through Mix inheritance. + /// + /// [StyleProvider] is how a control receives an unresolved style and still + /// resolves it against its own widget states, which is exactly what a + /// checkbox's checked appearance or a button's pressed appearance needs. + Widget _inheritStyle>(Style? style, Widget child) { + return style == null ? child : StyleProvider(style: style, child: child); + } + + Widget _checkbox(Widget child) => + _inheritStyle(_spec.selectionCheckbox, child); + + _DataTableSortIcons get _sortIcons => ( + sortable: _table.sortableIcon, + ascending: _table.sortAscendingIcon, + descending: _table.sortDescendingIcon, + ); + + RemixDataTableSortDirection? _sortStateOf(RemixDataTableColumn column) { + final descriptor = _table.sort; + if (!column.sortable || descriptor?.columnId != column.id) return null; + + return descriptor!.direction; + } + + String? _sortValueOf(RemixDataTableColumn column) { + return switch (_sortStateOf(column)) { + RemixDataTableSortDirection.ascending => _table.labels.sortedAscending, + RemixDataTableSortDirection.descending => _table.labels.sortedDescending, + null => null, + }; + } + + Widget _buildFooter(BuildContext context) { + final total = _table.totalRows!; + final pageSize = _table.pageSize; + final pageIndex = _table.pageIndex; + // The range describes what is actually on screen, so a short final page + // reports its real length instead of the nominal page size. + final visible = widget.rows.length; + final start = total == 0 || visible == 0 ? 0 : pageIndex * pageSize + 1; + final end = start == 0 ? 0 : start + visible - 1; + final lastPage = total == 0 ? 0 : (total - 1) ~/ pageSize; + final isRtl = Directionality.of(context) == TextDirection.rtl; + + return RowBox( + styleSpec: _spec.footer, + children: [ + StyledText(_table.labels.rowsPerPage, styleSpec: _spec.footerLabel), + _inheritStyle( + _spec.pageSizeSelect, + RemixSelect( + key: const ValueKey('remix-data-table-page-size'), + trigger: RemixSelectTrigger(placeholder: '$pageSize'), + items: [ + for (final option in widget.pageSizeOptions) + RemixSelectItem(value: option, label: '$option'), + ], + selectedValue: pageSize, + onChanged: (value) { + if (value != null) _table.onPageSizeChanged?.call(value); + }, + semanticLabel: _table.labels.rowsPerPage, + ), + ), + const Spacer(), + StyledText( + _table.pageRangeFormatter(start: start, end: end, total: total), + styleSpec: _spec.footerLabel, + ), + _inheritStyle( + _spec.pageButton, + RemixIconButton( + key: const ValueKey('remix-data-table-previous-page'), + // Chevrons are mirrored so "previous" always points toward the + // start of the reading direction. + icon: isRtl ? _table.nextPageIcon : _table.previousPageIcon, + semanticLabel: _table.labels.previousPage, + enabled: pageIndex > 0, + onPressed: () => _table.onPageChanged?.call(pageIndex - 1), + ), + ), + _inheritStyle( + _spec.pageButton, + RemixIconButton( + key: const ValueKey('remix-data-table-next-page'), + icon: isRtl ? _table.previousPageIcon : _table.nextPageIcon, + semanticLabel: _table.labels.nextPage, + enabled: pageIndex < lastPage, + onPressed: () => _table.onPageChanged?.call(pageIndex + 1), + ), + ), + ], + ); + } +} + +/// Documented layout floor for the optional selection column, on both axes. +/// +/// Every other spec dimension defaults to zero, but a zero-extent selection +/// cell would leave its checkbox with no room and no hit target. This matches +/// [RemixCheckbox.minimumTapTargetSize]'s own default, so an unstyled table +/// still gets a usable control. An explicit +/// [DataTableSpec.selectionColumnWidth], [DataTableSpec.headerMinHeight], or +/// [DataTableSpec.rowMinHeight] is used verbatim. +const double _defaultSelectionExtent = 48; + +typedef _DataTableSortIcons = ({ + IconData sortable, + IconData ascending, + IconData descending, +}); + +/// One header cell: the table's only `columnHeader` semantics node. +/// +/// A labelled header owns the announcement outright and excludes its visible +/// content, so a sortable header announces its name, its role, and its sort +/// state exactly once. That is why a custom [RemixDataTableColumn.header] +/// requires a `semanticLabel`. The selection column has no label of its own +/// and instead keeps the checkbox's native semantics as an explicit child. +class _DataTableHeaderCell extends StatelessWidget { + const _DataTableHeaderCell({ + required this.styles, + required this.minHeight, + this.semanticLabel, + this.alignment = RemixDataTableCellAlignment.start, + this.label, + this.child, + this.sortState, + this.sortValue, + this.onSort, + this.sortIcons, + this.useSelectionCell = false, + }); + + final _DataTableStyles styles; + final double? minHeight; + final String? semanticLabel; + final RemixDataTableCellAlignment alignment; + final String? label; + final Widget? child; + final RemixDataTableSortDirection? sortState; + final String? sortValue; + final VoidCallback? onSort; + final _DataTableSortIcons? sortIcons; + final bool useSelectionCell; + + Widget _buildContent(BuildContext context) { + final headerStyle = styles.headerLabel(context); + // A custom header inherits the header typography the way Radix cascades + // `th` styles onto arbitrary markup, without excluding its own semantics. + final content = child == null + ? StyledText(label!, styleSpec: headerStyle) + : RemixDefaultContentStyle(text: headerStyle, child: child!); + final icons = sortIcons; + if (onSort == null || icons == null) return content; + + return Row( + mainAxisSize: MainAxisSize.min, + spacing: styles.spec.sortIconSpacing ?? 0.0, + children: [ + // The indicator keeps its natural size and the label yields, so a + // narrow fixed column shrinks the text instead of overflowing. + Flexible(child: content), + StyledIcon( + icon: switch (sortState) { + RemixDataTableSortDirection.ascending => icons.ascending, + RemixDataTableSortDirection.descending => icons.descending, + null => icons.sortable, + }, + styleSpec: styles.sortIcon(context), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + Widget cell = Builder( + builder: (context) => _DataTableCellSurface( + row: styles.headerRow(context), + cell: useSelectionCell + ? styles.selectionCell(context) + : styles.headerCell(context), + alignment: alignment, + minHeight: minHeight, + child: _buildContent(context), + ), + ); + + if (onSort != null) { + // Pressable supplies the hover/press/focus scope that the row and cell + // regions above re-resolve against; its own button semantics are + // excluded because the columnHeader node below carries the tap action. + cell = Pressable( + excludeFromSemantics: true, + onPress: onSort, + child: cell, + ); + } + + final owned = semanticLabel != null; + + return Semantics( + role: SemanticsRole.columnHeader, + container: true, + explicitChildNodes: !owned, + excludeSemantics: owned, + button: onSort != null, + label: semanticLabel, + value: sortValue, + onTap: onSort, + child: cell, + ); + } +} + +/// One body cell, re-resolving its row and cell regions inside the row's +/// widget-state scope. +/// +/// The scope is inherited by the caller's cell content on purpose: a cell can +/// react to its row being hovered or selected. Remix controls such as +/// [RemixCheckbox] install their own scope, so their interaction visuals stay +/// driven by their own states. +class _DataTableBodyCell extends StatelessWidget { + const _DataTableBodyCell({ + required this.styles, + required this.states, + required this.isLastRow, + required this.minHeight, + required this.onHoverChanged, + required this.child, + this.alignment = RemixDataTableCellAlignment.start, + this.useSelectionCell = false, + }); + + final _DataTableStyles styles; + final Set states; + final bool isLastRow; + final double? minHeight; + final ValueChanged onHoverChanged; + final Widget child; + final RemixDataTableCellAlignment alignment; + final bool useSelectionCell; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => onHoverChanged(true), + onExit: (_) => onHoverChanged(false), + child: WidgetStateProvider( + states: states, + child: Builder( + builder: (context) => _DataTableCellSurface( + row: styles.bodyRow(context, isLast: isLastRow), + cell: useSelectionCell + ? styles.selectionCell(context) + : styles.bodyCell(context), + alignment: alignment, + minHeight: minHeight, + child: RemixDefaultContentStyle( + text: styles.cellText(context), + child: child, + ), + ), + ), + ), + ); + } +} + +/// The two boxes every cell paints: row chrome outside, cell padding inside. +class _DataTableCellSurface extends StatelessWidget { + const _DataTableCellSurface({ + required this.row, + required this.cell, + required this.alignment, + required this.minHeight, + required this.child, + }); + + final StyleSpec row; + final StyleSpec cell; + final RemixDataTableCellAlignment alignment; + final double? minHeight; + final Widget child; + + static AlignmentGeometry _alignmentOf(RemixDataTableCellAlignment value) { + return switch (value) { + RemixDataTableCellAlignment.start => AlignmentDirectional.centerStart, + RemixDataTableCellAlignment.center => AlignmentDirectional.center, + RemixDataTableCellAlignment.end => AlignmentDirectional.centerEnd, + }; + } + + @override + Widget build(BuildContext context) { + return Box( + styleSpec: row, + // The floor is outside the cell padding, matching Radix's border-box + // `height: var(--table-cell-min-height)` on `.rt-TableCell`. + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight ?? 0.0), + child: Box( + styleSpec: cell, + child: Align(alignment: _alignmentOf(alignment), child: child), + ), + ), + ); + } +} + +/// A [Table] whose semantics node also carries the table's accessible name. +/// +/// Deliberate: [RenderTable] already produces the whole role hierarchy — +/// `table` on itself, synthesized `row` nodes, and a `cell` wrapper for any +/// cell that is not already a `cell` or `columnHeader`. Adding a `Semantics` +/// ancestor would insert a second node above that one, so the label is written +/// straight into the render object's own configuration instead. +class _DataTableLayout extends Table { + _DataTableLayout({ + required this.semanticLabel, + required super.children, + required super.columnWidths, + required super.textDirection, + }) : super( + defaultVerticalAlignment: TableCellVerticalAlignment.intrinsicHeight, + ); + + final String? semanticLabel; + + @override + RenderTable createRenderObject(BuildContext context) { + return _RenderDataTable( + columns: children.isNotEmpty ? children[0].children.length : 0, + rows: children.length, + columnWidths: columnWidths, + defaultColumnWidth: defaultColumnWidth, + textDirection: textDirection ?? Directionality.of(context), + border: border, + configuration: createLocalImageConfiguration(context), + defaultVerticalAlignment: defaultVerticalAlignment, + textBaseline: textBaseline, + )..semanticLabel = semanticLabel; + } + + @override + void updateRenderObject(BuildContext context, RenderTable renderObject) { + super.updateRenderObject(context, renderObject); + (renderObject as _RenderDataTable).semanticLabel = semanticLabel; + } +} + +class _RenderDataTable extends RenderTable { + _RenderDataTable({ + super.columns, + super.rows, + super.columnWidths, + super.defaultColumnWidth, + required super.textDirection, + super.border, + super.configuration, + super.defaultVerticalAlignment, + super.textBaseline, + }); + + String? _semanticLabel; + set semanticLabel(String? value) { + if (value == _semanticLabel) return; + _semanticLabel = value; + markNeedsSemanticsUpdate(); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + final label = _semanticLabel; + if (label != null) { + config + ..label = label + ..textDirection = textDirection; + } + } +} diff --git a/packages/remix/lib/src/components/data_table/fortal_data_table_styles.dart b/packages/remix/lib/src/components/data_table/fortal_data_table_styles.dart new file mode 100644 index 00000000..44fc2e9f --- /dev/null +++ b/packages/remix/lib/src/components/data_table/fortal_data_table_styles.dart @@ -0,0 +1,163 @@ +part of 'data_table.dart'; + +/// Radix Themes Table size presets. +enum FortalDataTableSize { size1, size2, size3 } + +/// Radix Themes Table variants. +enum FortalDataTableVariant { surface, ghost } + +/// Resolved Radix `table.css` metrics for one size step. +typedef _FortalDataTableMetrics = ({ + double paddingX, + double paddingY, + double minHeight, + double sortIconSize, + Radius radius, + TextStyleToken text, +}); + +/// Fortal recipe for [RemixDataTable]. +/// +/// Sizes and variants map `@radix-ui/themes@3.3.0` `table.css` exactly: cell +/// padding, minimum cell height, typography, radius, the `gray-a5` row +/// divider, bold column headers, the surface panel/border, the `gray-a2` +/// header background, and the suppressed divider under a surface table's last +/// row. +/// +/// Sorting, selection, pagination, and row hover have no Radix counterpart — +/// Radix's Table is a passive layout. They are Fortal extensions built from +/// existing accent/gray control tokens and are recorded as extensions in the +/// parity manifest. +@MixWidget(target: RemixDataTable.new) +DataTableStyler fortalDataTableStyle({ + FortalDataTableSize size = .size2, + FortalDataTableVariant variant = .ghost, +}) { + final metrics = _fortalDataTableMetrics(size); + final base = DataTableStyler() + .cellText( + TextStyler(style: metrics.text.mix()).color(FortalTokens.gray12()), + ) + .headerLabel( + TextStyler(style: metrics.text.mix()) + .fontWeight(FortalTokens.fontWeightBold()) + .color(FortalTokens.gray12()), + ) + .footerLabel( + TextStyler(style: FortalTokens.text1.mix()) + .fontWeight(FortalTokens.fontWeightRegular()) + .color(FortalTokens.gray11()), + ) + .headerCell(_fortalDataTableCell(metrics)) + .bodyCell(_fortalDataTableCell(metrics)) + // The selection column is a Fortal extension with no Radix counterpart. + // It carries no padding of its own, so the composed checkbox's + // interaction target — sized to this cell — spans the whole column and + // the full row height instead of being inset from both. + .selectionCell(BoxStyler().alignment(Alignment.center)) + .headerMinHeight(metrics.minHeight) + .rowMinHeight(metrics.minHeight) + .selectionColumnWidth(FortalTokens.space8()) + .sortIconSpacing(FortalTokens.space1()) + .sortIcon( + IconStyler(color: FortalTokens.gray11(), size: metrics.sortIconSize), + ) + .headerRow(_fortalDataTableRowDivider()) + .bodyRow( + _fortalDataTableRowDivider() + .color(Colors.transparent) + // Hover and selection are Fortal extensions. Both are pure color + // layers, so a row never changes geometry when either applies. + .onHovered(.color(FortalTokens.grayA3())) + .onSelected( + .color( + FortalTokens.accentA3(), + ).onHovered(.color(FortalTokens.accentA4())), + ), + ) + .footer(_fortalDataTableFooter()) + .selectionCheckbox(fortalCheckboxStyle(size: .size1)) + .pageButton(fortalIconButtonStyle(variant: .ghost, size: .size1)) + .pageSizeSelect(fortalSelectStyle(variant: .ghost, size: .size1)); + + return switch (variant) { + .surface => _fortalDataTableSurface(base, metrics.radius), + .ghost => base.color(Colors.transparent), + }; +} + +_FortalDataTableMetrics _fortalDataTableMetrics(FortalDataTableSize size) => + switch (size) { + .size1 => ( + paddingX: FortalTokens.space2(), + paddingY: FortalTokens.space2(), + minHeight: FortalTokens.dataTableRowHeight1(), + sortIconSize: 14.0, + radius: FortalTokens.radius3(), + text: FortalTokens.text2, + ), + .size2 => ( + paddingX: FortalTokens.space3(), + paddingY: FortalTokens.space3(), + minHeight: FortalTokens.dataTableRowHeight2(), + sortIconSize: 16.0, + radius: FortalTokens.radius4(), + text: FortalTokens.text2, + ), + .size3 => ( + paddingX: FortalTokens.space4(), + paddingY: FortalTokens.space3(), + minHeight: FortalTokens.space8(), + sortIconSize: 18.0, + radius: FortalTokens.radius4(), + text: FortalTokens.text3, + ), + }; + +BoxStyler _fortalDataTableCell(_FortalDataTableMetrics metrics) => + BoxStyler().paddingX(metrics.paddingX).paddingY(metrics.paddingY); + +/// Radix draws the row divider as `inset 0 -1px var(--gray-a5)`, which paints +/// over the cell without reserving layout space. A foreground border is the +/// Flutter equivalent; a regular border would inset the cell content by 1px. +BoxStyler _fortalDataTableRowDivider() => BoxStyler().foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.bottom(_fortalDataTableDividerSide())), +); + +/// The `gray-a5` 1px edge shared by the row divider and the footer's top +/// border, so the footer reads as a continuation of the last row's divider. +BorderSideMix _fortalDataTableDividerSide() => + BorderSideMix(color: FortalTokens.grayA5(), width: 1); + +FlexBoxStyler _fortalDataTableFooter() => FlexBoxStyler() + .direction(.horizontal) + .crossAxisAlignment(.center) + .spacing(FortalTokens.space2()) + .paddingX(FortalTokens.space4()) + .paddingY(FortalTokens.space2()) + .foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.top(_fortalDataTableDividerSide())), + ); + +DataTableStyler _fortalDataTableSurface(DataTableStyler base, Radius radius) { + return base + .color(FortalTokens.colorPanel()) + .border( + BoxBorderMix.all( + BorderSideMix(color: FortalTokens.dataTableBorder(), width: 1), + ), + ) + .borderRadiusAll(radius) + .clipBehavior(Clip.antiAlias) + .containerEffects( + RemixBoxEffectsMix(backdropBlur: FortalTokens.panelBlur()), + ) + .headerRow(.color(FortalTokens.grayA2())) + // Radix clears `--table-row-box-shadow` on the surface variant's last + // row so its divider never doubles up with the panel border. + .lastBodyRow( + BoxStyler().foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.bottom(BorderSideMix.none)), + ), + ); +} diff --git a/packages/remix/lib/src/fortal/fortal_theme.dart b/packages/remix/lib/src/fortal/fortal_theme.dart index e3f9a13d..4f202d9f 100644 --- a/packages/remix/lib/src/fortal/fortal_theme.dart +++ b/packages/remix/lib/src/fortal/fortal_theme.dart @@ -500,6 +500,21 @@ class FortalTokens { 'fortal.tabs.active-letter-spacing.2', ); + /// Table size-1 minimum cell height (36px at 100% scaling). + /// + /// Radix writes `calc(36px * var(--scaling))` literally, so no existing + /// spacing step expresses it. + static const dataTableRowHeight1 = DoubleToken('fortal.data-table.height.1'); + + /// Table size-2 minimum cell height (44px at 100% scaling). + static const dataTableRowHeight2 = DoubleToken('fortal.data-table.height.2'); + + /// Table surface border - `color-mix(in oklab, gray-a5, gray-6)`. + /// + /// The existing `grayStroke*` tokens blend an alpha step with the *same* + /// numbered solid step at 25%; Table blends step 5 with step 6 at 50%. + static const dataTableBorder = ColorToken('fortal.data-table.border'); + /// Select's 1.5 × space-1 measurement (6px at 100% scaling). static const selectSpace1Half = DoubleToken('fortal.select.space.1-half'); @@ -1014,6 +1029,11 @@ Map _buildFortalScopeTokens(FortalThemeData theme) { tokens.gray.scale.step(7), 0.25, ), + FortalTokens.dataTableBorder: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(5), + tokens.gray.scale.step(6), + 0.5, + ), }; // Build base tokens map @@ -1032,6 +1052,8 @@ Map _buildFortalScopeTokens(FortalThemeData theme) { FortalTokens.space8: 48.0 * scaling, FortalTokens.space9: 64.0 * scaling, FortalTokens.spinnerSize3: 20.0 * scaling, + FortalTokens.dataTableRowHeight1: 36.0 * scaling, + FortalTokens.dataTableRowHeight2: 44.0 * scaling, FortalTokens.toggleGap1: 2.0 * scaling, FortalTokens.toggleGap3: 6.0 * scaling, FortalTokens.avatarSize6: 80.0 * scaling, diff --git a/packages/remix/reference/radix_themes_3_3_0/README.md b/packages/remix/reference/radix_themes_3_3_0/README.md index 4c5f7bda..8e79e8fa 100644 --- a/packages/remix/reference/radix_themes_3_3_0/README.md +++ b/packages/remix/reference/radix_themes_3_3_0/README.md @@ -28,3 +28,12 @@ fvm dart run tool/fortal_parity/check.dart Chromium reference output is kept under `chromium/` and is never used as a self-validating oracle for Flutter rendering. + +The `data_table` family is named for the Flutter component but cites Radix +`Table`. Only the passive visuals are parity claims: cell padding, minimum +cell height, typography, radius, the `gray-a5` row divider, bold column +headers, and the surface panel, border, `gray-a2` header background, and +suppressed last-row divider. Radix's Table has no data engine, so controlled +sorting, page-scoped selection, pagination, row hover, arbitrary row actions, +and horizontal scrolling are recorded as Flutter/Fortal extensions in +`flutterExceptions` rather than parity claims. diff --git a/packages/remix/reference/radix_themes_3_3_0/chromium/computed-styles.json b/packages/remix/reference/radix_themes_3_3_0/chromium/computed-styles.json index b7f291b1..cc0a40d5 100644 --- a/packages/remix/reference/radix_themes_3_3_0/chromium/computed-styles.json +++ b/packages/remix/reference/radix_themes_3_3_0/chromium/computed-styles.json @@ -6,7 +6,7 @@ "tarball": "https://registry.npmjs.org/@radix-ui/themes/-/themes-3.3.0.tgz" }, "generator": { - "chromium": "Google Chrome 150.0.7871.127", + "chromium": "Google Chrome 150.0.7871.187", "viewport": { "width": 1440, "height": 1280, @@ -192,6 +192,32 @@ "lineHeight": "24px" } }, + "data-table": { + "className": "rt-TableRoot rt-r-size-2 rt-variant-surface fixture-data-table", + "styles": { + "display": "block", + "position": "relative", + "width": "290px", + "height": "134px", + "minWidth": "auto", + "minHeight": "auto", + "padding": "0px", + "gap": "normal", + "borderWidth": "1px", + "borderColor": "oklab(0.809353 0.00127596 -0.0170646 / 0.560784)", + "borderRadius": "8px", + "backgroundColor": "rgba(255, 255, 255, 0.7)", + "color": "rgb(28, 32, 36)", + "boxShadow": "none", + "opacity": "1", + "transform": "none", + "filter": "none", + "backdropFilter": "blur(64px)", + "animationDuration": "0s", + "fontSize": "16px", + "lineHeight": "24px" + } + }, "dialog": { "className": "rt-BaseDialogContent rt-DialogContent rt-r-size-3 rt-r-align-center fixture-dialog-content", "styles": { diff --git a/packages/remix/reference/radix_themes_3_3_0/chromium/families-light.png b/packages/remix/reference/radix_themes_3_3_0/chromium/families-light.png index 74f7355f..348331a4 100644 Binary files a/packages/remix/reference/radix_themes_3_3_0/chromium/families-light.png and b/packages/remix/reference/radix_themes_3_3_0/chromium/families-light.png differ diff --git a/packages/remix/reference/radix_themes_3_3_0/coverage_evidence.json b/packages/remix/reference/radix_themes_3_3_0/coverage_evidence.json index 7b299929..56444609 100644 --- a/packages/remix/reference/radix_themes_3_3_0/coverage_evidence.json +++ b/packages/remix/reference/radix_themes_3_3_0/coverage_evidence.json @@ -245,6 +245,29 @@ "covers": ["state:disabled"] } ], + "data_table": [ + { + "test": "test/components/data_table/data_table_fortal_parity_test.dart", + "case": "public contract has the pinned enum order and Radix defaults", + "covers": [ + "enum:size.size1", + "enum:size.size2", + "enum:size.size3", + "enum:variant.surface", + "enum:variant.ghost" + ] + }, + { + "test": "test/components/data_table/data_table_widget_test.dart", + "case": "emits table, row, columnHeader, and cell roles", + "covers": ["state:idle", "state:passive"] + }, + { + "test": "test/components/data_table/data_table_fortal_parity_test.dart", + "case": "hover and selection are pure color layers on the row", + "covers": ["state:hovered", "state:selected"] + } + ], "dialog": [ { "test": "test/components/dialog/dialog_widget_test.dart", diff --git a/packages/remix/reference/radix_themes_3_3_0/manifest.json b/packages/remix/reference/radix_themes_3_3_0/manifest.json index 5dcae302..a591b3ba 100644 --- a/packages/remix/reference/radix_themes_3_3_0/manifest.json +++ b/packages/remix/reference/radix_themes_3_3_0/manifest.json @@ -1074,6 +1074,144 @@ "size": "generated Fortal constructor" } }, + { + "id": "data_table", + "fortalType": "FortalDataTable", + "radixComponent": "Table", + "parity": "mapped", + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "surface", + "ghost" + ], + "default": "ghost" + } + }, + "defaults": { + "enabled": true + }, + "supportedStyleProps": [], + "states": [ + "idle", + "passive", + "hovered", + "selected" + ], + "sourceFiles": [ + "src/components/table.props.tsx", + "src/components/table.css", + "src/components/table.tsx" + ], + "sourceSelectors": [ + ".rt-TableRoot", + ".rt-TableRootTable", + ".rt-TableHeader", + ".rt-TableBody", + ".rt-TableRow", + ".rt-TableCell", + ".rt-TableColumnHeaderCell", + ".rt-TableRowHeaderCell" + ], + "flutterExceptions": [ + "The mapped family is named data_table publicly but cites Radix Table. The recipe maps size and variant onto cell padding, minimum cell height, typography, radius, the gray-a5 row divider, bold column headers, and the surface panel, border, gray-a2 header background, and suppressed last-row divider.", + "Radix Table is a passive layout. Controlled sorting, page-scoped selection, pagination, row hover, arbitrary row actions, and horizontal scrolling are Flutter and Fortal extensions with no Radix counterpart, built from existing accent and gray control tokens.", + "Radix row-header cells use normal weight, but Flutter 3.44 exposes no row-header semantics role and the v1 data-driven API carries no row-header model, so .rt-TableRowHeaderCell is cited and deferred instead of fabricated.", + "Row background and divider are painted per cell exactly as .rt-TableCell does upstream, because Flutter's Table has no widget between the table and its cells." + ], + "coverage": { + "enums": [ + "size.size1", + "size.size2", + "size.size3", + "variant.surface", + "variant.ghost" + ], + "states": [ + "idle", + "passive", + "hovered", + "selected" + ], + "tests": [ + "test/components/data_table/data_table_fortal_parity_test.dart", + "test/components/data_table/data_table_widget_test.dart" + ] + }, + "upstreamInventory": { + "enums": { + "size": { + "values": [ + "size1", + "size2", + "size3" + ], + "default": "size2" + }, + "variant": { + "values": [ + "surface", + "ghost" + ], + "default": "ghost" + }, + "layout": { + "values": [ + "auto", + "fixed" + ], + "default": null + }, + "rowAlign": { + "values": [ + "start", + "center", + "end", + "baseline" + ], + "default": null + }, + "cellJustify": { + "values": [ + "start", + "center", + "end" + ], + "default": null + } + }, + "states": [ + "idle", + "passive" + ], + "supportedStyleProps": [] + }, + "supportedVisualStates": [ + "idle", + "passive" + ], + "deferredCapabilities": [ + "row-header cells (.rt-TableRowHeaderCell): Flutter has no row-header semantics role and the v1 data-driven API has no row-header model", + "responsive size and layout objects: Radix resolves breakpoints in CSS while Flutter callers rebuild with a different size", + "per-row align and per-cell width, min-width, max-width, and padding props: expressed as column-level TableColumnWidth, column alignment, and cell widgets instead", + "layout auto versus fixed: RenderTable always negotiates one shared column map" + ], + "visualMapping": { + "color": "FortalScope.accent", + "radius": "FortalScope.radius", + "highContrast": "not exposed by Radix Table", + "variant": "generated Fortal constructor", + "size": "generated Fortal constructor" + } + }, { "id": "dialog", "fortalType": "FortalDialog", diff --git a/packages/remix/test/components/data_table/data_table_fortal_parity_test.dart b/packages/remix/test/components/data_table/data_table_fortal_parity_test.dart new file mode 100644 index 00000000..082a5cf4 --- /dev/null +++ b/packages/remix/test/components/data_table/data_table_fortal_parity_test.dart @@ -0,0 +1,533 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +/// Pinned `@radix-ui/themes@3.3.0` `table.css` values at 100% scaling and the +/// default `medium` radius: +/// +/// ```css +/// .rt-r-size-1 { --table-cell-padding: var(--space-2); +/// --table-cell-min-height: calc(36px * var(--scaling)); +/// --table-border-radius: var(--radius-3); font-size: text-2 } +/// .rt-r-size-2 { --table-cell-padding: var(--space-3); +/// --table-cell-min-height: calc(44px * var(--scaling)); +/// --table-border-radius: var(--radius-4); font-size: text-2 } +/// .rt-r-size-3 { --table-cell-padding: var(--space-3) var(--space-4); +/// --table-cell-min-height: var(--space-8); +/// --table-border-radius: var(--radius-4); font-size: text-3 } +/// ``` +void main() { + test('public contract has the pinned enum order and Radix defaults', () { + expect(FortalDataTableSize.values, [ + FortalDataTableSize.size1, + FortalDataTableSize.size2, + FortalDataTableSize.size3, + ]); + expect(FortalDataTableVariant.values, [ + FortalDataTableVariant.surface, + FortalDataTableVariant.ghost, + ]); + + const table = FortalDataTable(rows: [], columns: []); + expect(table.size, FortalDataTableSize.size2); + expect(table.variant, FortalDataTableVariant.ghost); + expect( + const FortalDataTable.surface(rows: [], columns: []).variant, + FortalDataTableVariant.surface, + ); + expect( + const FortalDataTable.ghost(rows: [], columns: []).variant, + FortalDataTableVariant.ghost, + ); + }); + + group('sizes', () { + for (final (size, paddingX, paddingY, minHeight, fontSize, radius) + in const [ + (FortalDataTableSize.size1, 8.0, 8.0, 36.0, 14.0, 6.0), + (FortalDataTableSize.size2, 12.0, 12.0, 44.0, 14.0, 8.0), + (FortalDataTableSize.size3, 16.0, 12.0, 48.0, 16.0, 8.0), + ]) { + testWidgets('${size.name} matches the pinned Radix metrics', ( + tester, + ) async { + final spec = await _resolve( + tester, + fortalDataTableStyle(size: size, variant: .surface), + ); + + for (final cell in [spec.headerCell, spec.bodyCell]) { + expect( + cell.spec.padding!.resolve(TextDirection.ltr), + EdgeInsets.symmetric(horizontal: paddingX, vertical: paddingY), + ); + } + expect(spec.headerMinHeight, minHeight); + expect(spec.rowMinHeight, minHeight); + expect(spec.headerLabel.spec.style?.fontSize, fontSize); + expect(_radius(spec.container.spec), radius); + }); + } + + testWidgets('metrics scale with the theme', (tester) async { + final spec = await _resolve( + tester, + fortalDataTableStyle(size: .size2, variant: .surface), + scaling: .percent110, + ); + + expect(spec.rowMinHeight, closeTo(48.4, 1e-9)); + expect( + spec.bodyCell.spec.padding!.resolve(TextDirection.ltr).left, + closeTo(13.2, 1e-9), + ); + expect(_radius(spec.container.spec), closeTo(8.8, 1e-9)); + }); + }); + + group('shared typography and dividers', () { + testWidgets('column headers are bold gray-12', (tester) async { + final tokens = await _tokens(tester); + final spec = await _resolve(tester, fortalDataTableStyle()); + + expect(spec.headerLabel.spec.style?.color, tokens.gray12); + expect(spec.headerLabel.spec.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('row dividers use gray-a5 without insetting content', ( + tester, + ) async { + final tokens = await _tokens(tester); + final spec = await _resolve(tester, fortalDataTableStyle()); + + for (final row in [spec.headerRow, spec.bodyRow]) { + final decoration = row.spec.foregroundDecoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.bottom.color, tokens.grayA5); + expect(border.bottom.width, 1); + // The divider is a foreground border, so it reserves no layout space. + expect((row.spec.decoration as BoxDecoration?)?.border, isNull); + } + }); + }); + + group('variants', () { + testWidgets('surface uses the panel, the mixed border, and gray-a2', ( + tester, + ) async { + final tokens = await _tokens(tester); + final spec = await _resolve( + tester, + fortalDataTableStyle(variant: .surface), + ); + final container = spec.container.spec.decoration! as BoxDecoration; + + expect(container.color, tokens.panel); + expect((container.border! as Border).top.color, tokens.tableBorder); + expect((container.border! as Border).top.width, 1); + expect(spec.container.spec.clipBehavior, Clip.antiAlias); + expect(_color(spec.headerRow), tokens.grayA2); + }); + + testWidgets('surface drops the divider under the final body row', ( + tester, + ) async { + final spec = await _resolve( + tester, + fortalDataTableStyle(variant: .surface), + ); + final last = spec.lastBodyRow!.spec.foregroundDecoration! as BoxDecoration; + + expect((last.border! as Border).bottom.style, BorderStyle.none); + }); + + testWidgets('ghost keeps a transparent surface and every divider', ( + tester, + ) async { + final spec = await _resolve( + tester, + fortalDataTableStyle(variant: .ghost), + ); + + expect(_color(spec.container), Colors.transparent); + expect(spec.lastBodyRow, isNull); + expect(spec.bodyRow.spec.foregroundDecoration, isNotNull); + }); + + testWidgets('resolves in dark mode without losing its panel', ( + tester, + ) async { + final spec = await _resolve( + tester, + fortalDataTableStyle(variant: .surface), + brightness: Brightness.dark, + ); + final light = await _resolve( + tester, + fortalDataTableStyle(variant: .surface), + ); + + expect(_color(spec.container), isNotNull); + expect(_color(spec.container), isNot(_color(light.container))); + }); + }); + + group('Fortal extensions', () { + testWidgets('hover and selection are pure color layers on the row', ( + tester, + ) async { + final tokens = await _tokens(tester); + final idle = await _resolve(tester, fortalDataTableStyle()); + final hovered = await _resolve( + tester, + fortalDataTableStyle(), + states: {WidgetState.hovered}, + ); + final selected = await _resolve( + tester, + fortalDataTableStyle(), + states: {WidgetState.selected}, + ); + final both = await _resolve( + tester, + fortalDataTableStyle(), + states: {WidgetState.selected, WidgetState.hovered}, + ); + + expect(_color(idle.bodyRow), Colors.transparent); + expect(_color(hovered.bodyRow), tokens.grayA3); + expect(_color(selected.bodyRow), tokens.accentA3); + expect(_color(both.bodyRow), tokens.accentA4); + // No geometry moves between states. + expect(hovered.bodyCell.spec.padding, idle.bodyCell.spec.padding); + expect(selected.rowMinHeight, idle.rowMinHeight); + }); + + testWidgets('sort, selection, and pagination controls carry Fortal styles', ( + tester, + ) async { + final tokens = await _tokens(tester); + final spec = await _resolve(tester, fortalDataTableStyle()); + + expect(spec.sortIcon.spec.color, tokens.gray11); + expect(spec.sortIconSpacing, 4.0); + expect(spec.selectionColumnWidth, 48.0); + expect(spec.selectionCheckbox, isA()); + expect(spec.pageButton, isA()); + expect(spec.pageSizeSelect, isA()); + expect(spec.footerLabel.spec.style?.color, tokens.gray11); + }); + + testWidgets('composed controls receive an unresolved inherited style', ( + tester, + ) async { + // A pre-resolved spec would freeze each control in the table's widget + // state — a checked checkbox would lose its `onSelected` appearance — so + // the table hands them unresolved styles through Mix inheritance and + // each control resolves against its own states. + await tester.pumpWidget( + FortalScope( + child: WidgetsApp( + color: Colors.black, + builder: (context, child) => Align( + child: SizedBox( + width: 500, + child: FortalDataTable( + rows: const ['one'], + columns: [ + RemixDataTableColumn( + id: 'value', + label: 'Value', + cellBuilder: (context, row) => Text(row), + ), + ], + rowId: (row) => row, + onSelectionChanged: (_) {}, + totalRows: 42, + onPageChanged: (_) {}, + onPageSizeChanged: (_) {}, + ), + ), + ), + ), + ), + ); + + final checkboxElement = tester.element(find.byType(RemixCheckbox).first); + final checkboxStyle = Style.maybeOf(checkboxElement); + expect(checkboxStyle, isA()); + // Resolving in the control's own context proves the style arrived + // unresolved: it still carries the Radix size-1 checkbox geometry. + expect( + checkboxStyle! + .build(checkboxElement) + .spec + .container + .spec + .constraints + ?.maxWidth, + MixScope.tokenOf(FortalTokens.checkboxSize1, checkboxElement), + ); + + expect( + Style.maybeOf( + tester.element(find.byType(RemixIconButton).first), + ), + isA(), + ); + expect( + Style.maybeOf(tester.element(find.byType(RemixSelect))), + isA(), + ); + }); + }); + + group('rendered Fortal table', () { + testWidgets('renders a surface table with the pinned row height', ( + tester, + ) async { + await tester.pumpWidget( + FortalScope( + child: WidgetsApp( + color: Colors.black, + builder: (context, child) => Align( + child: SizedBox( + width: 400, + child: FortalDataTable.surface( + rows: const ['one', 'two'], + columns: [ + RemixDataTableColumn( + id: 'value', + label: 'Value', + cellBuilder: (context, row) => Text(row), + ), + ], + ), + ), + ), + ), + ), + ); + + final table = find.byWidgetPredicate((widget) => widget is Table); + // Header plus two body rows, each at the pinned 44px minimum. + expect(tester.getSize(table).height, 44 * 3); + expect(tester.takeException(), isNull); + + // The surface variant's last row merges lastBodyRow over bodyRow, so + // exactly one of the three rendered dividers is suppressed. + final dividers = tester + .widgetList(find.byType(Box)) + .map( + (box) => + (box.styleSpec?.spec.foregroundDecoration as BoxDecoration?) + ?.border, + ) + .whereType() + .map((border) => border.bottom.style) + .toList(); + expect(dividers, hasLength(3)); + expect( + dividers.where((style) => style == BorderStyle.none), + hasLength(1), + ); + expect(dividers.last, BorderStyle.none); + }); + + for (final (size, rowHeight) in const [ + (FortalDataTableSize.size1, 36.0), + (FortalDataTableSize.size2, 44.0), + (FortalDataTableSize.size3, 48.0), + ]) { + testWidgets('${size.name} keeps its pinned row height with selection', ( + tester, + ) async { + await _pumpSelectableTable(tester, size: size); + + final table = find.byWidgetPredicate((widget) => widget is Table); + // A composed control must not inflate the row past its Radix metric. + expect(tester.getSize(table).height, rowHeight * 2); + }); + } + + testWidgets('the selection target tracks the column at any scaling', ( + tester, + ) async { + await _pumpSelectableTable(tester, scaling: .percent90); + + // Flutter's Table lays every cell out at a tight width, so a target + // wider than the column would be silently clamped instead of honored. + expect( + tester.getSize(find.byType(RemixCheckbox).first), + const Size(48 * 0.9, 44 * 0.9), + ); + }); + + testWidgets('cell content inherits the gray-12 body typography', ( + tester, + ) async { + late Color? inherited; + late Color gray12; + await tester.pumpWidget( + FortalScope( + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + gray12 = MixScope.tokenOf(FortalTokens.gray12, context); + + return Align( + child: SizedBox( + width: 400, + child: FortalDataTable( + rows: const ['one'], + columns: [ + RemixDataTableColumn( + id: 'value', + label: 'Value', + cellBuilder: (context, row) => Builder( + builder: (context) { + inherited = DefaultTextStyle.of(context).style.color; + + return Text(row); + }, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + + expect(inherited, gray12); + }); + }); +} + +Future _pumpSelectableTable( + WidgetTester tester, { + FortalDataTableSize size = .size2, + FortalScaling scaling = .percent100, +}) async { + await tester.pumpWidget( + FortalScope( + scaling: scaling, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) => Align( + child: SizedBox( + width: 400, + child: FortalDataTable.surface( + size: size, + rows: const ['one'], + columns: [ + RemixDataTableColumn( + id: 'value', + label: 'Value', + cellBuilder: (context, row) => Text(row), + ), + ], + rowId: (row) => row, + onSelectionChanged: (_) {}, + ), + ), + ), + ), + ), + ); +} + +Future _resolve( + WidgetTester tester, + DataTableStyler style, { + FortalScaling scaling = .percent100, + Brightness brightness = Brightness.light, + Set states = const {}, +}) async { + late DataTableSpec result; + await tester.pumpWidget( + FortalScope( + brightness: brightness, + scaling: scaling, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) => WidgetStateProvider( + states: states, + child: Builder( + builder: (context) { + result = style.build(context).spec; + + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + + return result; +} + +Future< + ({ + Color panel, + Color tableBorder, + Color gray11, + Color gray12, + Color grayA2, + Color grayA3, + Color grayA5, + Color accentA3, + Color accentA4, + }) +> +_tokens(WidgetTester tester) async { + late ({ + Color panel, + Color tableBorder, + Color gray11, + Color gray12, + Color grayA2, + Color grayA3, + Color grayA5, + Color accentA3, + Color accentA4, + }) + result; + await tester.pumpWidget( + FortalScope( + brightness: .light, + child: WidgetsApp( + color: Colors.black, + builder: (context, child) { + Color token(ColorToken value) => MixScope.tokenOf(value, context); + result = ( + panel: token(FortalTokens.colorPanel), + tableBorder: token(FortalTokens.dataTableBorder), + gray11: token(FortalTokens.gray11), + gray12: token(FortalTokens.gray12), + grayA2: token(FortalTokens.grayA2), + grayA3: token(FortalTokens.grayA3), + grayA5: token(FortalTokens.grayA5), + accentA3: token(FortalTokens.accentA3), + accentA4: token(FortalTokens.accentA4), + ); + + return const SizedBox.shrink(); + }, + ), + ), + ); + + return result; +} + +double _radius(BoxSpec box) => (box.decoration! as BoxDecoration).borderRadius! + .resolve(TextDirection.ltr) + .topLeft + .x; + +Color? _color(StyleSpec style) => + (style.spec.decoration as BoxDecoration?)?.color; diff --git a/packages/remix/test/components/data_table/data_table_spec_test.dart b/packages/remix/test/components/data_table/data_table_spec_test.dart new file mode 100644 index 00000000..01d33ec9 --- /dev/null +++ b/packages/remix/test/components/data_table/data_table_spec_test.dart @@ -0,0 +1,257 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + group('DataTableSpec', () { + test('defaults every region and leaves every scalar null', () { + const spec = DataTableSpec(); + + expect(spec.container, isA>()); + expect(spec.headerRow, isA>()); + expect(spec.bodyRow, isA>()); + expect(spec.headerCell, isA>()); + expect(spec.bodyCell, isA>()); + expect(spec.selectionCell, isA>()); + expect(spec.footer, isA>()); + expect(spec.headerLabel, isA>()); + expect(spec.footerLabel, isA>()); + expect(spec.sortIcon, isA>()); + + expect(spec.lastBodyRow, isNull); + expect(spec.containerEffects, isNull); + expect(spec.selectionCheckbox, isNull); + expect(spec.pageButton, isNull); + expect(spec.pageSizeSelect, isNull); + expect(spec.headerMinHeight, isNull); + expect(spec.rowMinHeight, isNull); + expect(spec.selectionColumnWidth, isNull); + expect(spec.sortIconSpacing, isNull); + }); + + test('retains every supplied region and scalar', () { + final checkbox = CheckboxStyler().indicatorColor(Colors.red); + final pageButton = IconButtonStyler().iconColor(Colors.green); + final select = SelectStyler().content(SelectContentStyler().width(10)); + final spec = DataTableSpec( + container: StyleSpec(spec: BoxSpec(padding: const EdgeInsets.all(1))), + headerRow: StyleSpec(spec: BoxSpec(padding: const EdgeInsets.all(2))), + bodyRow: StyleSpec(spec: BoxSpec(padding: const EdgeInsets.all(3))), + lastBodyRow: StyleSpec( + spec: BoxSpec(padding: const EdgeInsets.all(4)), + ), + headerCell: StyleSpec(spec: BoxSpec(padding: const EdgeInsets.all(5))), + bodyCell: StyleSpec(spec: BoxSpec(padding: const EdgeInsets.all(6))), + selectionCell: StyleSpec( + spec: BoxSpec(padding: const EdgeInsets.all(7)), + ), + footer: const StyleSpec(spec: FlexBoxSpec()), + headerLabel: const StyleSpec(spec: TextSpec(maxLines: 1)), + footerLabel: const StyleSpec(spec: TextSpec(maxLines: 2)), + sortIcon: const StyleSpec(spec: IconSpec(size: 8)), + selectionCheckbox: checkbox, + pageButton: pageButton, + pageSizeSelect: select, + headerMinHeight: 36, + rowMinHeight: 44, + selectionColumnWidth: 48, + sortIconSpacing: 4, + ); + + expect(spec.container.spec.padding, const EdgeInsets.all(1)); + expect(spec.lastBodyRow!.spec.padding, const EdgeInsets.all(4)); + expect(spec.selectionCell.spec.padding, const EdgeInsets.all(7)); + expect(spec.headerLabel.spec.maxLines, 1); + expect(spec.footerLabel.spec.maxLines, 2); + expect(spec.sortIcon.spec.size, 8); + expect(spec.selectionCheckbox, same(checkbox)); + expect(spec.pageButton, same(pageButton)); + expect(spec.pageSizeSelect, same(select)); + expect(spec.headerMinHeight, 36); + expect(spec.rowMinHeight, 44); + expect(spec.selectionColumnWidth, 48); + expect(spec.sortIconSpacing, 4); + }); + + test('retains negative metrics for the renderer to reject', () { + const spec = DataTableSpec(rowMinHeight: -1); + + expect(spec.rowMinHeight, -1); + }); + + test('copyWith replaces only the named fields', () { + const original = DataTableSpec(rowMinHeight: 10, sortIconSpacing: 2); + final updated = original.copyWith(rowMinHeight: 20); + + expect(updated.rowMinHeight, 20); + expect(updated.sortIconSpacing, 2); + expect(original.rowMinHeight, 10); + }); + + test('lerp interpolates scalars and holds unresolved control styles', () { + final checkbox = CheckboxStyler().indicatorColor(Colors.red); + const from = DataTableSpec(rowMinHeight: 0, selectionColumnWidth: 0); + final to = DataTableSpec( + rowMinHeight: 40, + selectionColumnWidth: 20, + selectionCheckbox: checkbox, + ); + + final middle = from.lerp(to, 0.5); + + expect(middle.rowMinHeight, 20); + expect(middle.selectionColumnWidth, 10); + // Control styles are unresolved handoffs, not animatable values. + expect(middle.selectionCheckbox, same(checkbox)); + expect(from.lerp(null, 0.5), same(from)); + }); + + test('lerp animates container effects instead of snapping', () { + const from = DataTableSpec( + containerEffects: RemixBoxEffectsSpec(backdropBlur: 0), + ); + const to = DataTableSpec( + containerEffects: RemixBoxEffectsSpec(backdropBlur: 40), + ); + + expect(from.lerp(to, 0.5).containerEffects?.backdropBlur, 20); + }); + + test('equality and hashCode follow every field', () { + const a = DataTableSpec(rowMinHeight: 10); + const b = DataTableSpec(rowMinHeight: 10); + const c = DataTableSpec(rowMinHeight: 11); + + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(c)); + }); + + test('debugFillProperties lists every region and scalar', () { + const spec = DataTableSpec(rowMinHeight: 44, sortIconSpacing: 4); + final builder = DiagnosticPropertiesBuilder(); + spec.debugFillProperties(builder); + final names = builder.properties.map((p) => p.name).toList(); + + expect( + names, + containsAll([ + 'container', + 'containerEffects', + 'headerRow', + 'bodyRow', + 'lastBodyRow', + 'headerCell', + 'bodyCell', + 'selectionCell', + 'footer', + 'headerLabel', + 'footerLabel', + 'sortIcon', + 'selectionCheckbox', + 'pageButton', + 'pageSizeSelect', + 'headerMinHeight', + 'rowMinHeight', + 'selectionColumnWidth', + 'sortIconSpacing', + ]), + ); + }); + }); + + group('RemixDataTableSort', () { + test('is value equal', () { + const a = RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.ascending, + ); + const b = RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.ascending, + ); + const c = RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.descending, + ); + + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(c)); + expect(a.toString(), contains('name')); + }); + }); + + group('RemixDataTableColumn', () { + test('rejects supplying both a label and a header', () { + expect( + () => RemixDataTableColumn( + id: 'a', + label: 'A', + header: const SizedBox(), + semanticLabel: 'A', + cellBuilder: (_, _) => const SizedBox(), + ), + throwsAssertionError, + ); + }); + + test('rejects a custom header without a semantic label', () { + expect( + () => RemixDataTableColumn( + id: 'a', + header: const SizedBox(), + cellBuilder: (_, _) => const SizedBox(), + ), + throwsAssertionError, + ); + }); + + test('rejects an empty id or label', () { + expect( + () => RemixDataTableColumn( + id: '', + label: 'A', + cellBuilder: (_, _) => const SizedBox(), + ), + throwsAssertionError, + ); + expect( + () => RemixDataTableColumn( + id: 'a', + label: '', + cellBuilder: (_, _) => const SizedBox(), + ), + throwsAssertionError, + ); + }); + }); + + group('RemixDataTableLabels', () { + test('defaults to English strings that callers can replace wholesale', () { + const labels = RemixDataTableLabels(); + + expect(labels.rowsPerPage, 'Rows per page'); + expect(labels.previousPage, 'Previous page'); + expect(labels.nextPage, 'Next page'); + expect(labels.selectAllRows, 'Select all rows on this page'); + expect(labels.selectRow, 'Select row'); + expect(labels.sortedAscending, 'sorted ascending'); + expect(labels.sortedDescending, 'sorted descending'); + }); + }); + + group('remixDefaultDataTablePageRangeFormatter', () { + test('formats one-based ranges and the empty case', () { + expect( + remixDefaultDataTablePageRangeFormatter(start: 1, end: 10, total: 42), + '1–10 of 42', + ); + expect( + remixDefaultDataTablePageRangeFormatter(start: 0, end: 0, total: 0), + '0–0 of 0', + ); + }); + }); +} diff --git a/packages/remix/test/components/data_table/data_table_style_test.dart b/packages/remix/test/components/data_table/data_table_style_test.dart new file mode 100644 index 00000000..b058af04 --- /dev/null +++ b/packages/remix/test/components/data_table/data_table_style_test.dart @@ -0,0 +1,490 @@ +import 'package:flutter/gestures.dart' show PointerDeviceKind; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +import '../../helpers/test_helpers.dart'; + +class _Record { + const _Record(this.id, this.name); + + final String id; + final String name; +} + +const _records = [_Record('a', 'Ada'), _Record('b', 'Blaise')]; + +final Finder _tableFinder = find.byWidgetPredicate((widget) => widget is Table); + +List> _columns({ + TableColumnWidth first = const FixedColumnWidth(120), + TableColumnWidth second = const FlexColumnWidth(), +}) { + return [ + RemixDataTableColumn<_Record>( + id: 'name', + label: 'Name', + width: first, + cellBuilder: (context, row) => Text(row.name), + ), + RemixDataTableColumn<_Record>( + id: 'id', + label: 'Id', + width: second, + cellBuilder: (context, row) => Text(row.id), + ), + ]; +} + +/// Horizontal offset of [finder] relative to the table's own left edge. +double _left(WidgetTester tester, Finder finder) => + tester.getRect(finder).left - tester.getRect(_tableFinder).left; + +/// Every visible [Box] painted for one region, matched by its resolved color. +List _boxColors(WidgetTester tester) { + return tester + .widgetList(find.byType(Box)) + .map( + (box) => (box.styleSpec?.spec.decoration as BoxDecoration?)?.color, + ) + .toList(); +} + +Widget _table({ + DataTableStyler? style, + DataTableSpec? styleSpec, + bool selectable = false, + Set selected = const {}, + double minimumWidth = 0, + List>? columns, +}) { + return RemixDataTable<_Record>( + rows: _records, + columns: columns ?? _columns(), + minimumWidth: minimumWidth, + rowId: selectable ? (row) => row.id : null, + selectedRowIds: selected, + onSelectionChanged: selectable ? (_) {} : null, + style: style ?? const DataTableStyler.create(), + styleSpec: styleSpec, + ); +} + +void main() { + group('column widths', () { + testWidgets('header and body share one column map', (tester) async { + await tester.pumpRemixApp(SizedBox(width: 600, child: _table())); + + expect(_left(tester, find.text('Name')), _left(tester, find.text('Ada'))); + expect(_left(tester, find.text('Id')), _left(tester, find.text('a'))); + }); + + testWidgets('the fixed column keeps its width when selection is added', ( + tester, + ) async { + await tester.pumpRemixApp(SizedBox(width: 600, child: _table())); + final withoutSelection = + _left(tester, find.text('Id')) - _left(tester, find.text('Name')); + expect(_left(tester, find.text('Name')), 0); + + await tester.pumpRemixApp( + SizedBox(width: 600, child: _table(selectable: true)), + ); + + expect( + _left(tester, find.text('Id')) - _left(tester, find.text('Name')), + withoutSelection, + ); + // The whole map shifts by exactly the inserted selection column. + expect(_left(tester, find.text('Name')), 48); + }); + + testWidgets('selection column parity survives across every row', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox(width: 600, child: _table(selectable: true)), + ); + + final checkboxes = tester + .widgetList(find.byType(RemixCheckbox)) + .toList(); + expect(checkboxes, hasLength(_records.length + 1)); + final lefts = find + .byType(RemixCheckbox) + .evaluate() + .map((element) => tester.getRect(find.byWidget(element.widget)).left) + .toSet(); + expect(lefts, hasLength(1)); + }); + }); + + group('bounded width', () { + testWidgets('lays out at the viewport width when it exceeds the minimum', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox(width: 500, child: _table(minimumWidth: 300)), + ); + + expect(tester.getSize(_tableFinder).width, 500); + }); + + testWidgets('lays out at the minimum width and scrolls when narrower', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox(width: 200, child: _table(minimumWidth: 640)), + ); + + expect(tester.getSize(_tableFinder).width, 640); + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('resolves flex columns under an unbounded parent', ( + tester, + ) async { + await tester.pumpRemixApp( + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _table(minimumWidth: 400), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(_tableFinder).width, greaterThanOrEqualTo(400)); + }); + + testWidgets('does not overflow at a very narrow viewport', (tester) async { + await tester.pumpRemixApp(SizedBox(width: 60, child: _table())); + + expect(tester.takeException(), isNull); + }); + }); + + group('regions', () { + testWidgets('paints row chrome behind every cell of a row', (tester) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + style: DataTableStyler() + .headerRow(BoxStyler().color(const Color(0xFF111111))) + .bodyRow(BoxStyler().color(const Color(0xFF222222))), + ), + ), + ); + + final colors = _boxColors(tester); + // Two header cells plus two cells for each of the two rows. + expect(colors.where((c) => c == const Color(0xFF111111)), hasLength(2)); + expect(colors.where((c) => c == const Color(0xFF222222)), hasLength(4)); + }); + + testWidgets('lastBodyRow merges over bodyRow for the final row only', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + style: DataTableStyler() + .bodyRow(BoxStyler().color(const Color(0xFF222222))) + .lastBodyRow(BoxStyler().color(const Color(0xFF333333))), + ), + ), + ); + + final colors = _boxColors(tester); + expect(colors.where((c) => c == const Color(0xFF222222)), hasLength(2)); + expect(colors.where((c) => c == const Color(0xFF333333)), hasLength(2)); + }); + + testWidgets('the selection column uses selectionCell, not bodyCell', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + selectable: true, + style: DataTableStyler() + .bodyCell(BoxStyler().color(const Color(0xFF444444))) + .selectionCell(BoxStyler().color(const Color(0xFF555555))), + ), + ), + ); + + final colors = _boxColors(tester); + expect(colors.where((c) => c == const Color(0xFF444444)), hasLength(4)); + // One header selection cell plus one per row. + expect(colors.where((c) => c == const Color(0xFF555555)), hasLength(3)); + }); + + testWidgets('applies header typography and the sort indicator style', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: RemixDataTable<_Record>( + rows: _records, + columns: [ + RemixDataTableColumn<_Record>( + id: 'name', + label: 'Name', + sortable: true, + cellBuilder: (context, row) => Text(row.name), + ), + ], + onSortChanged: (_) {}, + style: DataTableStyler() + .headerLabelColor(const Color(0xFF00FF00)) + .sortIconColor(const Color(0xFF0000FF)), + ), + ), + ); + + final header = tester.widget(find.text('Name')); + expect(header.style?.color, const Color(0xFF00FF00)); + expect( + tester.widget(find.byIcon(Icons.unfold_more)).color, + const Color(0xFF0000FF), + ); + }); + + testWidgets('a raw spec bypasses styler resolution', (tester) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + styleSpec: DataTableSpec( + bodyRow: StyleSpec( + spec: BoxSpec( + decoration: const BoxDecoration(color: Color(0xFF666666)), + ), + ), + ), + ), + ), + ); + + expect( + _boxColors(tester).where((c) => c == const Color(0xFF666666)), + hasLength(4), + ); + }); + }); + + group('widget states', () { + testWidgets('selected rows resolve their own onSelected variant', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + selectable: true, + selected: const {'a'}, + style: DataTableStyler().bodyRow( + BoxStyler() + .color(const Color(0xFF777777)) + .onSelected(BoxStyler().color(const Color(0xFF888888))), + ), + ), + ), + ); + + final colors = _boxColors(tester); + // The selected row's three cells, and the unselected row's three. + expect(colors.where((c) => c == const Color(0xFF888888)), hasLength(3)); + expect(colors.where((c) => c == const Color(0xFF777777)), hasLength(3)); + }); + + testWidgets('hovering any cell highlights its whole row', (tester) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + style: DataTableStyler().bodyRow( + BoxStyler() + .color(const Color(0xFF777777)) + .onHovered(BoxStyler().color(const Color(0xFF999999))), + ), + ), + ), + ); + + 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('selection visuals do not change row geometry', (tester) async { + final style = DataTableStyler() + .bodyRow( + BoxStyler() + .color(const Color(0xFF777777)) + .onSelected(BoxStyler().color(const Color(0xFF888888))), + ) + .rowMinHeight(40); + + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table(selectable: true, style: style), + ), + ); + final unselected = tester.getSize(_tableFinder); + + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + selectable: true, + selected: const {'a', 'b'}, + style: style, + ), + ), + ); + + expect(tester.getSize(_tableFinder), unselected); + }); + }); + + group('metrics', () { + testWidgets('rowMinHeight and headerMinHeight set the row floors', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + style: DataTableStyler().headerMinHeight(60).rowMinHeight(30), + ), + ), + ); + + expect(tester.getSize(_tableFinder).height, 60 + 30 * _records.length); + }); + + testWidgets('each selection checkbox fills its own row', (tester) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + selectable: true, + style: DataTableStyler() + .selectionColumnWidth(52) + .headerMinHeight(80) + .rowMinHeight(24), + ), + ), + ); + + // The header and body floors differ, so a checkbox wired to the wrong + // one would still leave the surrounding cell correct and go unnoticed. + expect( + tester.getSize(find.byType(RemixCheckbox).first), + const Size(52, 80), + ); + expect( + tester.getSize(find.byType(RemixCheckbox).at(1)), + const Size(52, 24), + ); + }); + + testWidgets('selectionColumnWidth sets the leading column width', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + selectable: true, + style: DataTableStyler().selectionColumnWidth(72), + ), + ), + ); + + expect(_left(tester, find.text('Name')), 72); + }); + + testWidgets('a negative resolved dimension fails in debug builds', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table(style: DataTableStyler().rowMinHeight(-1)), + ), + ); + + expect( + tester.takeException(), + isA().having( + (error) => error.message, + 'message', + contains('non-negative'), + ), + ); + }); + }); + + group('DataTableStyler helpers', () { + testWidgets('cellPadding reaches header, body, and selection cells', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + selectable: true, + style: DataTableStyler().cellPadding( + EdgeInsetsGeometryMix.all(9), + ), + ), + ), + ); + + final paddings = tester + .widgetList(find.byType(Box)) + .map((box) => box.styleSpec?.spec.padding) + .whereType() + .toSet(); + expect(paddings, {const EdgeInsets.all(9)}); + }); + + testWidgets('rowDivider draws under header and body rows', (tester) async { + await tester.pumpRemixApp( + SizedBox( + width: 400, + child: _table( + style: DataTableStyler().rowDivider( + BorderSideMix(color: const Color(0xFFAAAAAA), width: 2), + ), + ), + ), + ); + + final borders = tester + .widgetList(find.byType(Box)) + .map( + (box) => + (box.styleSpec?.spec.decoration as BoxDecoration?)?.border, + ) + .whereType() + .toList(); + expect(borders, hasLength(6)); + expect(borders.first.bottom.color, const Color(0xFFAAAAAA)); + expect(borders.first.bottom.width, 2); + }); + }); +} diff --git a/packages/remix/test/components/data_table/data_table_widget_test.dart b/packages/remix/test/components/data_table/data_table_widget_test.dart new file mode 100644 index 00000000..58f73297 --- /dev/null +++ b/packages/remix/test/components/data_table/data_table_widget_test.dart @@ -0,0 +1,748 @@ +import 'dart:ui' show CheckedState, SemanticsRole; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart' hide SemanticsRole; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +import '../../helpers/test_helpers.dart'; + +class _Record { + const _Record(this.id, this.name, this.amount); + + final String id; + final String name; + final int amount; +} + +const _records = [ + _Record('a', 'Ada', 3), + _Record('b', 'Blaise', 1), + _Record('c', 'Curie', 2), +]; + +/// The renderer uses a private [Table] subclass, so match by subtype. +final Finder _tableFinder = find.byWidgetPredicate((widget) => widget is Table); + +List> _columns({bool sortable = false}) { + return [ + RemixDataTableColumn<_Record>( + id: 'name', + label: 'Name', + sortable: sortable, + cellBuilder: (context, row) => Text(row.name), + ), + RemixDataTableColumn<_Record>( + id: 'amount', + label: 'Amount', + alignment: RemixDataTableCellAlignment.end, + cellBuilder: (context, row) => Text('${row.amount}'), + ), + ]; +} + +SemanticsNode? _find( + SemanticsNode root, + bool Function(SemanticsData data) predicate, +) { + if (predicate(root.getSemanticsData())) return root; + SemanticsNode? found; + root.visitChildren((child) { + found ??= _find(child, predicate); + + return found == null; + }); + + return found; +} + +List _children(SemanticsNode node) { + final children = []; + node.visitChildren((child) { + children.add(child); + + return true; + }); + + return children; +} + +SemanticsNode _tableNode(WidgetTester tester) { + final anchor = tester.getSemantics(_tableFinder); + final node = _find(anchor, (data) => data.role == SemanticsRole.table); + expect(node, isNotNull, reason: 'Expected a table semantics node.'); + + return node!; +} + +void main() { + group('RemixDataTable structure', () { + testWidgets('renders one header row plus one row per record', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>(rows: _records, columns: _columns()), + ); + + expect(find.text('Name'), findsOneWidget); + expect(find.text('Amount'), findsOneWidget); + for (final record in _records) { + expect(find.text(record.name), findsOneWidget); + } + }); + + testWidgets('operates without a Material ancestor', (tester) async { + await tester.pumpWidget( + FortalScope( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + child: RemixDataTable<_Record>( + rows: _records, + columns: _columns(), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Ada'), findsOneWidget); + }); + + testWidgets('keeps a rendered snapshot of a mutated rows list', ( + tester, + ) async { + final rows = [..._records]; + await tester.pumpRemixApp( + RemixDataTable<_Record>(rows: rows, columns: _columns()), + ); + rows.clear(); + await tester.pump(); + + expect(find.text('Ada'), findsOneWidget); + }); + + testWidgets('renders the empty builder while keeping the header', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: const [], + columns: _columns(), + emptyBuilder: (context) => const Text('No results'), + ), + ); + + expect(find.text('Name'), findsOneWidget); + expect(find.text('No results'), findsOneWidget); + }); + + testWidgets('infers its generic argument from typed columns', ( + tester, + ) async { + final table = RemixDataTable( + rows: _records, + columns: >[ + RemixDataTableColumn( + id: 'name', + label: 'Name', + cellBuilder: (context, row) => Text(row.name.toUpperCase()), + ), + ], + ); + await tester.pumpRemixApp(table); + + expect(table, isA>()); + expect(find.text('ADA'), findsOneWidget); + }); + + testWidgets('rejects duplicate column ids in debug builds', (tester) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: _records, + columns: [ + RemixDataTableColumn<_Record>( + id: 'name', + label: 'Name', + cellBuilder: (context, row) => Text(row.name), + ), + RemixDataTableColumn<_Record>( + id: 'name', + label: 'Copy', + cellBuilder: (context, row) => Text(row.name), + ), + ], + ), + ); + + expect( + tester.takeException(), + isA().having( + (error) => error.message, + 'message', + contains('duplicate id'), + ), + ); + }); + }); + + group('RemixDataTable sorting', () { + testWidgets('cycles ascending then descending without reordering rows', ( + tester, + ) async { + final emitted = []; + RemixDataTableSort? sort; + await tester.pumpRemixApp( + StatefulBuilder( + builder: (context, setState) => RemixDataTable<_Record>( + rows: _records, + columns: _columns(sortable: true), + sort: sort, + onSortChanged: (value) { + emitted.add(value); + setState(() => sort = value); + }, + ), + ), + ); + + await tester.tap(find.text('Name')); + await tester.pump(); + await tester.tap(find.text('Name')); + await tester.pump(); + + expect(emitted, [ + const RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.ascending, + ), + const RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.descending, + ), + ]); + // Row order is caller-owned; the table never reorders what it was given. + final names = tester + .widgetList(find.byType(Text)) + .map((text) => text.data) + .toList(); + expect( + names.indexOf('Ada') < names.indexOf('Blaise'), + isTrue, + reason: 'Supplied row order must survive a sort emission.', + ); + }); + + testWidgets('announces sort state on the column header node once', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: _records, + columns: _columns(sortable: true), + sort: const RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.descending, + ), + onSortChanged: (_) {}, + ), + ); + + final header = _children(_tableNode(tester)).first; + final headerCells = _children(header); + final data = headerCells.first.getSemanticsData(); + + expect(data.role, SemanticsRole.columnHeader); + expect(data.label, 'Name'); + expect(data.value, 'sorted descending'); + expect(data.hasAction(SemanticsAction.tap), isTrue); + expect(headerCells.first.childrenCount, 0); + + handle.dispose(); + }); + + testWidgets('reports a header tap through its semantics action', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + RemixDataTableSort? emitted; + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: _records, + columns: _columns(sortable: true), + onSortChanged: (value) => emitted = value, + ), + ); + + final header = _children(_tableNode(tester)).first; + tester.binding.performSemanticsAction( + SemanticsActionEvent( + type: SemanticsAction.tap, + nodeId: _children(header).first.id, + viewId: tester.view.viewId, + ), + ); + await tester.pump(); + + expect(emitted?.columnId, 'name'); + handle.dispose(); + }); + + testWidgets('overrides the announced sort state through labels', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: _records, + columns: _columns(sortable: true), + labels: const RemixDataTableLabels(sortedAscending: 'croissant'), + sort: const RemixDataTableSort( + columnId: 'name', + direction: RemixDataTableSortDirection.ascending, + ), + onSortChanged: (_) {}, + ), + ); + + final header = _children(_tableNode(tester)).first; + expect(_children(header).first.getSemanticsData().value, 'croissant'); + handle.dispose(); + }); + }); + + group('RemixDataTable selection', () { + Widget buildSelectable({ + required Set selected, + required ValueChanged> onChanged, + }) { + return RemixDataTable<_Record>( + rows: _records, + columns: _columns(), + rowId: (row) => row.id, + selectedRowIds: selected, + onSelectionChanged: onChanged, + ); + } + + testWidgets('emits a fresh unmodifiable set when a row toggles', ( + tester, + ) async { + Set? emitted; + await tester.pumpRemixApp( + buildSelectable(selected: const {}, onChanged: (v) => emitted = v), + ); + + await tester.tap(find.byType(RemixCheckbox).at(1)); + await tester.pump(); + + expect(emitted, {'a'}); + expect(() => emitted!.add('z'), throwsUnsupportedError); + }); + + testWidgets('select-all is page scoped and keeps other pages selected', ( + tester, + ) async { + Set? emitted; + await tester.pumpRemixApp( + buildSelectable( + selected: const {'offpage'}, + onChanged: (v) => emitted = v, + ), + ); + + await tester.tap( + find.byKey(const ValueKey('remix-data-table-select-all')), + ); + await tester.pump(); + + expect(emitted, {'offpage', 'a', 'b', 'c'}); + }); + + testWidgets('select-all clears only the visible rows', (tester) async { + Set? emitted; + await tester.pumpRemixApp( + buildSelectable( + selected: const {'offpage', 'a', 'b', 'c'}, + onChanged: (v) => emitted = v, + ), + ); + + await tester.tap( + find.byKey(const ValueKey('remix-data-table-select-all')), + ); + await tester.pump(); + + expect(emitted, {'offpage'}); + }); + + testWidgets('renders no selection column without both selection inputs', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>(rows: _records, columns: _columns()), + ); + + expect(find.byType(RemixCheckbox), findsNothing); + }); + + testWidgets('rejects half of the selection contract in debug builds', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: _records, + columns: _columns(), + rowId: (row) => row.id, + ), + ); + + expect( + tester.takeException(), + isA().having( + (error) => error.message, + 'message', + contains('rowId and onSelectionChanged'), + ), + ); + }); + + testWidgets('exposes checkbox semantics exactly once per selection cell', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpRemixApp( + buildSelectable(selected: const {'a'}, onChanged: (_) {}), + ); + + final rows = _children(_tableNode(tester)); + final firstBodyCells = _children(rows[1]); + expect(firstBodyCells.first.getSemanticsData().role, SemanticsRole.cell); + final checkboxes = []; + void collect(SemanticsNode node) { + if (node.getSemanticsData().flagsCollection.isChecked != + CheckedState.none) { + checkboxes.add(node); + } + node.visitChildren((child) { + collect(child); + + return true; + }); + } + + collect(firstBodyCells.first); + expect(checkboxes, hasLength(1)); + expect( + checkboxes.single.getSemanticsData().flagsCollection.isChecked, + CheckedState.isTrue, + ); + + handle.dispose(); + }); + + testWidgets('select-all reports none, mixed, and all as one node', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + Future selectAllState(Set selected) async { + await tester.pumpRemixApp( + buildSelectable(selected: selected, onChanged: (_) {}), + ); + final headerCells = _children(_children(_tableNode(tester)).first); + // The unlabelled selection header keeps the checkbox as its one child. + expect( + headerCells.first.getSemanticsData().role, + SemanticsRole.columnHeader, + ); + final children = _children(headerCells.first); + expect(children, hasLength(1)); + + return children.single.getSemanticsData().flagsCollection.isChecked; + } + + expect(await selectAllState(const {}), CheckedState.isFalse); + expect(await selectAllState(const {'a'}), CheckedState.mixed); + expect( + await selectAllState(const {'a', 'b', 'c'}), + CheckedState.isTrue, + ); + + handle.dispose(); + }); + }); + + group('RemixDataTable pagination', () { + Widget buildPaginated({ + int pageIndex = 0, + RemixDataTableLabels labels = const RemixDataTableLabels(), + RemixDataTablePageRangeFormatter formatter = + remixDefaultDataTablePageRangeFormatter, + ValueChanged? onPageChanged, + ValueChanged? onPageSizeChanged, + }) { + return RemixDataTable<_Record>( + rows: _records, + columns: _columns(), + totalRows: 42, + pageIndex: pageIndex, + pageSize: 10, + labels: labels, + pageRangeFormatter: formatter, + onPageChanged: onPageChanged ?? (_) {}, + onPageSizeChanged: onPageSizeChanged ?? (_) {}, + ); + } + + testWidgets('reports the one-based range of the visible rows', ( + tester, + ) async { + await tester.pumpRemixApp(buildPaginated(pageIndex: 1)); + + // Page 2 of a 10-per-page result set starts at 11, and only three rows + // were supplied, so the range describes what is actually on screen. + expect(find.text('11–13 of 42'), findsOneWidget); + }); + + testWidgets('reports a zero range for an empty result set', (tester) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: const [], + columns: _columns(), + totalRows: 0, + pageSize: 10, + onPageChanged: (_) {}, + onPageSizeChanged: (_) {}, + ), + ); + + expect(find.text('0–0 of 0'), findsOneWidget); + }); + + testWidgets('routes the range through a caller-supplied formatter', ( + tester, + ) async { + await tester.pumpRemixApp( + buildPaginated( + formatter: ({required start, required end, required total}) => + '$total ← $end..$start', + ), + ); + + expect(find.text('42 ← 3..1'), findsOneWidget); + }); + + testWidgets('disables previous on the first page and advances on next', ( + tester, + ) async { + final pages = []; + await tester.pumpRemixApp(buildPaginated(onPageChanged: pages.add)); + + await tester.tap( + find.byKey(const ValueKey('remix-data-table-previous-page')), + ); + await tester.pump(); + expect(pages, isEmpty); + + await tester.tap( + find.byKey(const ValueKey('remix-data-table-next-page')), + ); + await tester.pump(); + expect(pages, [1]); + }); + + testWidgets('localizes every built-in control label', (tester) async { + await tester.pumpRemixApp( + buildPaginated( + labels: const RemixDataTableLabels( + rowsPerPage: 'Linhas por página', + previousPage: 'Anterior', + nextPage: 'Próxima', + ), + ), + ); + + expect(find.text('Linhas por página'), findsOneWidget); + expect( + tester + .widget( + find.byKey(const ValueKey('remix-data-table-previous-page')), + ) + .semanticLabel, + 'Anterior', + ); + expect( + tester + .widget( + find.byKey(const ValueKey('remix-data-table-next-page')), + ) + .semanticLabel, + 'Próxima', + ); + }); + + testWidgets('renders no footer without the full pagination contract', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>(rows: _records, columns: _columns()), + ); + + expect(find.byType(RemixIconButton), findsNothing); + }); + + testWidgets('keeps pagination outside the structural table node', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpRemixApp(buildPaginated()); + + for (final row in _children(_tableNode(tester))) { + expect(row.getSemanticsData().role, SemanticsRole.row); + for (final cell in _children(row)) { + expect( + cell.getSemanticsData().role, + anyOf(SemanticsRole.cell, SemanticsRole.columnHeader), + ); + } + } + + handle.dispose(); + }); + }); + + group('RemixDataTable semantics and hosts', () { + testWidgets('emits table, row, columnHeader, and cell roles', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: _records, + columns: _columns(), + semanticLabel: 'Customers', + ), + ); + + final table = _tableNode(tester); + expect(table.getSemanticsData().label, 'Customers'); + + final rows = _children(table); + expect(rows, hasLength(_records.length + 1)); + expect( + _children(rows.first).map((node) => node.getSemanticsData().role), + everyElement(SemanticsRole.columnHeader), + ); + expect( + _children(rows[1]).map((node) => node.getSemanticsData().role), + everyElement(SemanticsRole.cell), + ); + + handle.dispose(); + }); + + testWidgets('keeps interactive cell content actionable', (tester) async { + var pressed = 0; + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: _records, + columns: [ + RemixDataTableColumn<_Record>( + id: 'actions', + header: const Icon(Icons.more_horiz), + semanticLabel: 'Actions', + cellBuilder: (context, row) => RemixButton( + key: ValueKey('action-${row.id}'), + label: 'Edit ${row.id}', + onPressed: () => pressed += 1, + ), + ), + ], + ), + ); + + await tester.tap(find.byKey(const ValueKey('action-a'))); + await tester.pump(); + + expect(pressed, 1); + }); + + testWidgets('mirrors the pagination chevrons right to left', ( + tester, + ) async { + Future pumpPagination(TextDirection direction) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>( + rows: _records, + columns: _columns(), + totalRows: 42, + onPageChanged: (_) {}, + onPageSizeChanged: (_) {}, + ), + textDirection: direction, + ); + + return tester.getRect( + find.byKey(const ValueKey('remix-data-table-previous-page')), + ); + } + + final ltr = await pumpPagination(TextDirection.ltr); + expect( + tester + .widget( + find.byKey(const ValueKey('remix-data-table-previous-page')), + ) + .icon, + Icons.chevron_left, + ); + + final rtl = await pumpPagination(TextDirection.rtl); + expect( + tester + .widget( + find.byKey(const ValueKey('remix-data-table-previous-page')), + ) + .icon, + Icons.chevron_right, + ); + // Icon and position mirror together, so "previous" always points back. + expect(rtl.left, lessThan(ltr.left)); + }); + + testWidgets('lays out right to left without overflow', (tester) async { + await tester.pumpRemixApp( + RemixDataTable<_Record>(rows: _records, columns: _columns()), + textDirection: TextDirection.rtl, + ); + + expect(tester.takeException(), isNull); + final name = tester.getRect(find.text('Name')); + final amount = tester.getRect(find.text('Amount')); + expect(name.left, greaterThan(amount.left)); + }); + + testWidgets('survives a high text scale', (tester) async { + await tester.pumpWidget( + FortalScope( + child: MaterialApp( + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(3)), + child: Scaffold( + body: RemixDataTable<_Record>( + rows: _records, + columns: _columns(), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/packages/remix/test/public_api_compatibility_test.dart b/packages/remix/test/public_api_compatibility_test.dart index 2f97d608..79caac6f 100644 --- a/packages/remix/test/public_api_compatibility_test.dart +++ b/packages/remix/test/public_api_compatibility_test.dart @@ -63,6 +63,7 @@ void main() { const dataList = RemixDataList( items: [RemixDataListItem(label: 'Status', value: 'Active')], ); + const dataTable = RemixDataTable(rows: [], columns: []); expect(button.label, 'Save'); expect(card.child, isA()); @@ -103,6 +104,18 @@ void main() { expect(dataList.orientation, Axis.horizontal); expect(dataList.style, isA()); expect(dataList.styleSpec, isNull); + expect(dataTable.pageIndex, 0); + expect(dataTable.pageSize, 10); + expect(dataTable.pageSizeOptions, const [10, 20, 50]); + expect(dataTable.minimumWidth, 0); + expect(dataTable.selectedRowIds, isEmpty); + expect(dataTable.labels.rowsPerPage, 'Rows per page'); + expect( + dataTable.pageRangeFormatter, + same(remixDefaultDataTablePageRangeFormatter), + ); + expect(dataTable.style, isA()); + expect(dataTable.styleSpec, isNull); }); test('menu data retains caller-owned identity', () { diff --git a/packages/remix/test/public_api_test.dart b/packages/remix/test/public_api_test.dart index 0381bbab..7f8f894c 100644 --- a/packages/remix/test/public_api_test.dart +++ b/packages/remix/test/public_api_test.dart @@ -172,6 +172,66 @@ void main() { expect(called.excludeSemantics, isTrue); }); + test('the data table family is constructible from the public API', () { + final columns = >[ + RemixDataTableColumn( + id: 'value', + label: 'Value', + sortable: true, + width: const FixedColumnWidth(120), + alignment: RemixDataTableCellAlignment.end, + cellBuilder: (context, row) => Text(row), + ), + ]; + const spec = DataTableSpec( + headerMinHeight: 36, + rowMinHeight: 44, + selectionColumnWidth: 48, + sortIconSpacing: 4, + ); + final raw = RemixDataTable( + key: const ValueKey('raw'), + rows: const ['one'], + columns: columns, + semanticLabel: 'Values', + sort: const RemixDataTableSort( + columnId: 'value', + direction: RemixDataTableSortDirection.ascending, + ), + onSortChanged: (_) {}, + rowId: (row) => row, + selectedRowIds: const {'one'}, + onSelectionChanged: (_) {}, + totalRows: 1, + pageSizeOptions: const [10, 20, 50], + onPageChanged: (_) {}, + onPageSizeChanged: (_) {}, + minimumWidth: 640, + emptyBuilder: (context) => const Text('Empty'), + labels: const RemixDataTableLabels(rowsPerPage: 'Per page'), + pageRangeFormatter: remixDefaultDataTablePageRangeFormatter, + styleSpec: spec, + ); + final DataTableStyler style = raw.style; + final DataTableSpec? styleSpec = raw.styleSpec; + final DataTableStyler styler = RemixDataTable.styleFrom(rowMinHeight: 44); + + expect(raw, isA>()); + expect(raw.columns, same(columns)); + expect(style, isA()); + expect(styleSpec, same(spec)); + expect(styler, isA()); + + const fortal = FortalDataTable.surface( + rows: ['one'], + columns: [], + size: FortalDataTableSize.size3, + ); + expect(fortal, isA>()); + expect(fortal.variant, FortalDataTableVariant.surface); + expect(fortalDataTableStyle(), isA()); + }); + test('mode-aware Fortal filters are constructible from the public API', () { final modifier = fortalModeAwareFilter( light: const [RemixCssColorFilterOperation.brightness(1.1)], diff --git a/packages/remix/tool/fortal_parity/check.dart b/packages/remix/tool/fortal_parity/check.dart index cc9f30b6..eadb1d22 100644 --- a/packages/remix/tool/fortal_parity/check.dart +++ b/packages/remix/tool/fortal_parity/check.dart @@ -13,6 +13,7 @@ const _expectedMappedFamilies = { 'callout', 'card', 'checkbox', + 'data_table', 'dialog', 'divider', 'menu', @@ -293,8 +294,8 @@ void _checkFamilies( failures, ); _expect( - families.length == 23, - 'Exactly 23 Fortal families must be tracked.', + families.length == 24, + 'Exactly 24 Fortal families must be tracked.', failures, ); } @@ -830,7 +831,7 @@ Never _finish(List failures) { } stdout.writeln( 'Verified @radix-ui/themes 3.3.0 contract: ' - '20 mapped families, 3 Fortal extensions, Chromium fixtures, ' + '21 mapped families, 3 Fortal extensions, Chromium fixtures, ' 'coverage ledger, hosted Naked $_expectedNakedUiVersion resolution, and no ' 'undocumented approximations.', ); diff --git a/packages/remix/tool/fortal_parity/chromium/fixture.html b/packages/remix/tool/fortal_parity/chromium/fixture.html index 8d00e1ff..7870de40 100644 --- a/packages/remix/tool/fortal_parity/chromium/fixture.html +++ b/packages/remix/tool/fortal_parity/chromium/fixture.html @@ -15,7 +15,7 @@ .fixture-cell { position: relative; display: flex; - min-height: 190px; + min-height: 156px; flex-direction: column; justify-content: center; align-items: flex-start; @@ -35,6 +35,7 @@ .fixture-slider { width: 240px; flex-grow: 0; } .fixture-text-field { width: 250px; } .fixture-tooltip { position: relative; } + .fixture-data-table { width: 100%; } @@ -56,6 +57,7 @@

Radix Themes 3.3.0 — Fortal mapped families

Callout
!

Callout text

Card
Checkbox
+
Table
NameQty
Ada3
Blaise1
Dialog
Dialog titleDialog content
Divider
Menu
diff --git a/packages/remix/tool/fortal_parity/chromium/generate.mjs b/packages/remix/tool/fortal_parity/chromium/generate.mjs index 15152d23..4dda75fc 100644 --- a/packages/remix/tool/fortal_parity/chromium/generate.mjs +++ b/packages/remix/tool/fortal_parity/chromium/generate.mjs @@ -102,6 +102,9 @@ writeFileSync( ); const screenshot = join(outputDirectory, 'families-light.png'); +// The completion probe below only checks that the file exists, so a stale +// capture from a previous run would satisfy it immediately. +rmSync(screenshot, { force: true }); await runChromium( [ '--window-size=1440,1280',