@astroapps/datagrid-fluent-ui
v1.1.0
Published
Style @astroapps/datagrid to match the FluentUI v9 DataGrid
Readme
@astroapps/datagrid-fluent-ui
Styles @astroapps/datagrid to look like the
FluentUI v9 DataGrid, and wires up the row-level
behaviour Fluent gets from its row elements.
The searching — sort, filter, paging, filter options, client vs server — belongs
to @astroapps/datagrid-search, as does anything else a
second renderer would need: row selection, and the rules for which row clicks
count and when a pager is worth showing. This package renders what that gives it
and adds no logic of its own. Read its README for how the state works; this one
covers the Fluent side.
Every metric here was measured against a live Fluent v9 DataGrid rather than
copied from docs — see src/app/fluentgrid/page.tsx in the formServer site for
the harness that diffs the two grids' computed styles, and
src/app/fluentgrid/features/page.tsx for a tour of the search features.
npm i @astroapps/datagrid-search @astroapps/datagrid-fluent-uiA whole grid
const state = useControl<SearchRequest>({
...defaultSearchOptions,
length: 25,
});
const data = useClientData(state, { rows, columns }); // or useServerData
const search = useGridSearch(state, { columns, data });
<FluentDataGrid search={search} size="medium" rowKey={(r) => r.id} />;That's a sortable, filterable, paged grid. Everything it renders follows from the search rather than a flag:
| Affordance | Appears when |
| ---------------- | --------------------------------------------------------------- |
| Sort arrow | the column has a sortField |
| Multi-sort badge | sort mode isn't "single" and more than one column is sorted |
| Filter funnel | the column's filter options resolve (see datagrid-search) |
| Pager | there's a page before or after this one, or pageSizes is set |
| Selection column | a selection is passed |
So a grid whose columns carry none of that renders as a plain table. Grid-wide
off switches exist for the blunt cases: sortable={false}, filterable={false},
pager={false}.
Must be rendered inside a FluentProvider.
Props beyond the search
<FluentDataGrid
search={search}
size="medium" // "medium" | "small" | "extra-small"
rowKey={(r) => r.id}
selection={selection}
noData="Nothing here"
pager // false to suppress, or a node to replace
pageSizes={[10, 25, 50]}
renderFilterPopup={(props) => <MyBody {...props} />}
renderFilterControl={(column, search) => <MyInlineFilter />}
renderHeaderExtra={(column) => <ColumnInfo column={column} />}
/>DataGridClasses props (className, cellClass, …) pass through and win over
the Fluent defaults.
Composing it yourself
When you need to own the <DataGrid> call — extra header rows, a custom body
wrapper — use the hook and spread its parts:
const fluent = useFluentDataGrid(search, { size, selection, rowKey });
<DataGrid
{...fluent.gridProps}
columns={fluent.columns} // selection column already prepended
bodyRows={search.data.rowProps.bodyRows}
getBodyRow={search.data.rowProps.getBodyRow}
/>;gridProps carries the classes, renderHeaderContent, the row wrapper and the
per-cell RenderControl wrapper. fluent.parts exposes the part classes, for
building custom cells that still look like Fluent's.
The pieces are also exported individually — useFluentDataGridStyles,
fluentHeaderContent, fluentRowWrapper, fluentSelectionColumn,
FluentFilterPopover, FilterOptionList, FluentPager — for grids that need to
compose them differently.
Selection
Page-scoped: the header checkbox reflects and acts on the rows currently rendered, and never disturbs a selection made on another page.
const selectedIds = useControl<string[]>([]);
const selection = makeGridSelection({
selected: selectedIds,
rows: data.rows, // the current page
getId: (r) => r.id,
});arraySelection is the same thing over plain state. Neither is a hook, despite
taking a control — they read .value when called, so call them during render.
Cross-page "select all N matching" is deliberately not supported: it needs the filtered total, the live search, and a way to fetch every matching id, at which point selection stops being a renderer concern.
Adding to a header
renderHeaderExtra renders after the filter funnel, for anything a column wants
in its header that sorting and filtering don't cover:
renderHeaderExtra={(column) =>
column.id === "margin" ? (
<InfoTip className="shrink-0" text="Gross margin, ex-GST" />
) : undefined
}It is a sibling of the sort button, not a child, so it can hold interactive
content — nesting a button inside the sort button would be invalid HTML. The sort
button fills the cell, so an extra that must stay visible needs flexShrink: 0.
To wrap the whole cell instead — a tooltip covering the title, arrow and funnel
together — use the column's own renderHeader from @astroapps/datagrid, going
through defaultRenderCell so the cell keeps its grid placement.
Custom filter popups
Three levels, most specific first:
getColumnFilter(column).render— replaces one column's popup body, keeping the standard funnel and the loading/error/empty shell.renderFilterPopupon the grid — the same, as the default for every column.renderFilterControlon the grid — replaces the funnel and popup, for a column that wants an inline control instead.
The body receives FilterPopupProps, whose whole contract is a
Control<string[] | undefined> for that column. FilterOptionList is exported so
a custom body can keep the standard checkbox list and just add a header.
The body is a separate component mounted only while the popover is open, which is what makes an async option source lazy.
Finding rows and cells in tests
The grid is a single flat CSS grid: there's no <table>, and the row wrapper is
display: contents, which takes it out of the accessibility tree — so neither
getByRole("row") nor DOM walking gets you a cell. Address them by data
attribute instead:
| Attribute | Where | Value |
| ---------------- | -------------------- | ------------------------------------------ |
| data-column | every cell | the column's id (its title by default) |
| data-row-index | body cells, rows | 0-based index within the page |
| data-row-key | row wrapper | your rowKey, else the index |
// By identity — survives sorting and paging, given a `rowKey`.
page.locator('[data-row-key="42"] [data-column="status"]');
// By position.
page.locator('[data-row-index="2"] [data-column="status"]');
// A whole column, header included.
page.locator('[data-column="status"]');
// The header cell alone.
page.locator('[data-column="status"]:not([data-row-index])');data-row-key needs a rowKey, which is worth passing anyway — React reorders
rather than rebuilds rows on sort:
<FluentDataGrid search={search} rowKey={(r) => r.id} />Sort buttons and filter funnels are reachable through the same attributes, and
each funnel is named after its column — Filter (Kind), or
Filter (Kind, filtered) once something is applied:
page.locator('[data-column="status"] button').first(); // the sort button
page.getByLabel("Filter (Status)");Row checkboxes are labelled "Select row" by default, which makes every row's
checkbox identical to getByLabelText. Name the row instead:
<FluentDataGrid
search={search}
selection={selection}
selectionColumn={{ rowAriaLabel: (r) => `Select ${r.name}` }}
/>Excel-mode filtering
filterMode is a useGridSearch option, not a prop on this component — the
funnels read it off the search. Set it to "excel" and every filter popup opens
with each value ticked when the column is unfiltered, above a (Select All) row:
const search = useGridSearch(state, { columns, data, filterMode: "excel" });Applying with everything ticked stores no filter at all, so the funnel goes back to idle; applying with nothing ticked is refused, and the Apply button is disabled to say so. Clear re-ticks everything, which is the way back out of an empty selection. The rules, and why the empty selection can't be stored, are in the datagrid-search README.
FilterOptionList takes the row as a selectAll prop, so a custom popup body
can keep it:
<FilterOptionList
options={options}
selected={values}
onToggle={draft.toggle}
selectAll={{ checked, indeterminate, onToggle }}
/>Styling
useFluentDataGridStyles returns the classes; the grid contributes no CSS of its
own beyond them. Stable class names are on fluentDataGridClassNames, for CSS
overrides and for tests that measure rendered cells.
Sizes match Fluent's: body rows are 44/34/32px of content, the header row is 32px
at every size, and extra-small drops the row divider and uses 12px text.
Two structural differences from Fluent worth knowing:
- No row box. The row wrapper is
display: contents, so cells stay direct grid items and keep their explicit placement. Hover and selection are painted on the cells rather than the row —:hoverstill resolves up the ancestor chain. - Column widths. Fluent's resizable column sizing reserves a constant ~48px of the container and gives the slack to the last column, which a CSS grid template won't reproduce. Set the widths you want explicitly rather than letting either side distribute slack.
Pager
Fluent v9 ships no pagination component, so FluentPager is built from its
primitives rather than matching a reference. It renders prev/next over
offset/length with an optional page-size selector, and hides itself when
everything fits on one page — unless pageSizes is set, since that selector is
the only way back from a size that fits every row. Then it stays, with prev/next
disabled.
It copes with a source that doesn't count: without a total it shows 1-10 instead
of 1-10 of 42, and enables Next while the page comes back full. See
pageInfo in datagrid-search for why counting is optional.
