From 40cdd300c281d0c4819e757e9ac3a5cb557bc1c1 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 5 Aug 2026 17:53:49 -0400 Subject: [PATCH] feat(remix): add data table component Adds RemixDataTable and the generated FortalDataTable recipe: a host-neutral table for comparing many records under shared column headers. Flutter's core Table is the layout and semantics authority, so every row negotiates one column-width map and RenderTable emits the native table/row/columnHeader/cell role hierarchy. Sorting, page-scoped selection, and pagination are controlled signals only. Remix never fetches, filters, sorts, or slices caller data: rows is already the current page in its final order. Labels and the page-range formatter keep the component independent of MaterialLocalizations. Row and cell style regions re-resolve inside each row's own widget-state scope, so onHovered/onSelected variants evaluate against that row. The composed checkbox, page buttons, and page-size select receive unresolved styles through Mix's StyleProvider inheritance, because each control has to resolve its own widget states. Maps the pinned Radix Themes 3.3.0 table.css for sizes 1-3 and the surface/ghost variants, advancing the Fortal parity ledger from 20 to 21 mapped families. Sorting, selection, pagination, hover, and horizontal scrolling have no Radix counterpart and are recorded as Fortal extensions. Migrates the dashboard's Customers, Orders, and Overview pages onto the new component and deletes the private DataGrid. --- README.md | 3 + docs.json | 4 + docs/components/data_table.mdx | 486 +++++++ .../dashboard/lib/pages/customers_page.dart | 133 +- packages/dashboard/lib/pages/orders_page.dart | 124 +- .../dashboard/lib/pages/overview_page.dart | 22 +- packages/dashboard/lib/utils/date_format.dart | 18 + packages/dashboard/lib/utils/pagination.dart | 19 + .../dashboard/lib/widgets/action_popover.dart | 16 + packages/dashboard/lib/widgets/data_grid.dart | 398 ----- .../lib/widgets/data_table_cell_text.dart | 24 + packages/dashboard/test/app_smoke_test.dart | 32 +- .../lib/registry/component_registry.dart | 5 + .../registry/entries/data_table_entry.dart | 170 +++ .../playground/lib/routes/all_components.dart | 2 + packages/remix/README.md | 2 + packages/remix/lib/remix.dart | 1 + .../src/components/data_table/data_table.dart | 25 + .../components/data_table/data_table.g.dart | 1274 +++++++++++++++++ .../data_table/data_table_spec.dart | 183 +++ .../data_table/data_table_style.dart | 46 + .../data_table/data_table_widget.dart | 1212 ++++++++++++++++ .../data_table/fortal_data_table_styles.dart | 163 +++ .../remix/lib/src/fortal/fortal_theme.dart | 22 + .../reference/radix_themes_3_3_0/README.md | 9 + .../chromium/computed-styles.json | 28 +- .../chromium/families-light.png | Bin 85163 -> 82923 bytes .../radix_themes_3_3_0/coverage_evidence.json | 23 + .../radix_themes_3_3_0/manifest.json | 138 ++ .../data_table_fortal_parity_test.dart | 533 +++++++ .../data_table/data_table_spec_test.dart | 257 ++++ .../data_table/data_table_style_test.dart | 490 +++++++ .../data_table/data_table_widget_test.dart | 748 ++++++++++ .../test/public_api_compatibility_test.dart | 13 + packages/remix/test/public_api_test.dart | 60 + packages/remix/tool/fortal_parity/check.dart | 7 +- .../tool/fortal_parity/chromium/fixture.html | 4 +- .../tool/fortal_parity/chromium/generate.mjs | 3 + 38 files changed, 6107 insertions(+), 590 deletions(-) create mode 100644 docs/components/data_table.mdx create mode 100644 packages/dashboard/lib/utils/date_format.dart create mode 100644 packages/dashboard/lib/utils/pagination.dart delete mode 100644 packages/dashboard/lib/widgets/data_grid.dart create mode 100644 packages/dashboard/lib/widgets/data_table_cell_text.dart create mode 100644 packages/playground/lib/registry/entries/data_table_entry.dart create mode 100644 packages/remix/lib/src/components/data_table/data_table.dart create mode 100644 packages/remix/lib/src/components/data_table/data_table.g.dart create mode 100644 packages/remix/lib/src/components/data_table/data_table_spec.dart create mode 100644 packages/remix/lib/src/components/data_table/data_table_style.dart create mode 100644 packages/remix/lib/src/components/data_table/data_table_widget.dart create mode 100644 packages/remix/lib/src/components/data_table/fortal_data_table_styles.dart create mode 100644 packages/remix/test/components/data_table/data_table_fortal_parity_test.dart create mode 100644 packages/remix/test/components/data_table/data_table_spec_test.dart create mode 100644 packages/remix/test/components/data_table/data_table_style_test.dart create mode 100644 packages/remix/test/components/data_table/data_table_widget_test.dart diff --git a/README.md b/README.md index 9004b034..dce5d741 100644 --- a/README.md +++ b/README.md @@ -285,8 +285,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 568debd1..700be9ac 100644 --- a/docs.json +++ b/docs.json @@ -109,6 +109,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 6d3906f3..43bc1369 100644 --- a/packages/playground/lib/registry/component_registry.dart +++ b/packages/playground/lib/registry/component_registry.dart @@ -10,6 +10,7 @@ import 'entries/callout_entry.dart'; import 'entries/card_entry.dart'; import 'entries/checkbox_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'; @@ -87,6 +88,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 302d539e..f270ed68 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'; @@ -48,6 +49,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 54c59ced..951f85c4 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -286,6 +286,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 d82fcad9..7765b33e 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 74f7355f16896ba86f8acf90e6fc0666fcc700b7..348331a45545db0d1638f17391da32e576802917 100644 GIT binary patch literal 82923 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 CsYtc} literal 85163 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 D-(#v# 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 07754da2..b3aabed7 100644 --- a/packages/remix/test/public_api_compatibility_test.dart +++ b/packages/remix/test/public_api_compatibility_test.dart @@ -45,6 +45,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()); @@ -73,6 +74,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 1c56b882..be8a4e81 100644 --- a/packages/remix/test/public_api_test.dart +++ b/packages/remix/test/public_api_test.dart @@ -93,6 +93,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 651db828..343eaf54 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',