feat(opal): support alignment overrides in Table cells and headers (#13768)

This commit is contained in:
roshan authored and GitHub committed 2026-08-06 00:11:56 +00:00
1 parent 0fbb1b8764
commit a9df89bffa
10 files changed
+106 -25

No files matched your search

@@ -63,7 +63,11 @@ function Calendar(props: CalendarProps) {
style={undefined}
styles={undefined}
formatters={undefined}
modifiersClassNames={undefined}
modifiersClassNames={{
range_preview_start: "opal-calendar-cell--range-preview-start",
range_preview_middle: "opal-calendar-cell--range-preview-middle",
range_preview_end: "opal-calendar-cell--range-preview-end",
}}
modifiersStyles={undefined}
// Outside days render as empty fixed-width cells, never as numerals.
showOutsideDays={false}
@@ -91,7 +91,10 @@
neighbor-facing side. Week edges stay flush and rounded. */
.opal-calendar-cell--range-start::before,
.opal-calendar-cell--range-middle::before,
.opal-calendar-cell--range-end::before {
.opal-calendar-cell--range-end::before,
.opal-calendar-cell--range-preview-start::before,
.opal-calendar-cell--range-preview-middle::before,
.opal-calendar-cell--range-preview-end::before {
content: "";
position: absolute;
top: 0;
@@ -103,7 +106,10 @@
.opal-calendar-cell--range-start::before,
.opal-calendar-cell--range-middle:first-child::before,
.opal-calendar-cell--range-end:first-child::before {
.opal-calendar-cell--range-end:first-child::before,
.opal-calendar-cell--range-preview-start::before,
.opal-calendar-cell--range-preview-middle:first-child::before,
.opal-calendar-cell--range-preview-end:first-child::before {
left: 0;
border-top-left-radius: var(--radius-08);
border-bottom-left-radius: var(--radius-08);
@@ -111,7 +117,10 @@
.opal-calendar-cell--range-end::before,
.opal-calendar-cell--range-middle:last-child::before,
.opal-calendar-cell--range-start:last-child::before {
.opal-calendar-cell--range-start:last-child::before,
.opal-calendar-cell--range-preview-end::before,
.opal-calendar-cell--range-preview-middle:last-child::before,
.opal-calendar-cell--range-preview-start:last-child::before {
right: 0;
border-top-right-radius: var(--radius-08);
border-bottom-right-radius: var(--radius-08);
@@ -78,7 +78,7 @@ function DragOverlayRowInner<TData>({
}
return (
<TableCell key={cell.id}>
<TableCell key={cell.id} alignment={colDef?.alignment}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
+19 -17
View File
@@ -48,23 +48,24 @@ function UsersTable({ users }: { users: User[] }) {
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` | `TData[]` | required | Row data array |
| `columns` | `OnyxColumnDef<TData>[]` | required | Column definitions from `createTableColumns()` |
| `getRowId` | `(row: TData) => string` | required | Unique row identifier |
| `pageSize` | `number` | `10` | Rows per page (`Infinity` disables pagination) |
| `size` | `"md" \| "lg"` | `"lg"` | Density variant |
| `footer` | `DataTableFooterConfig` | — | Footer configuration (mode is derived from `selectionBehavior`) |
| `initialSorting` | `SortingState` | — | Initial sort state |
| `initialColumnVisibility` | `VisibilityState` | — | Initial column visibility |
| `draggable` | `DataTableDraggableConfig` | — | Enable drag-and-drop reordering |
| `onSelectionChange` | `(ids: string[]) => void` | — | Selection callback |
| `onRowClick` | `(row: TData) => void` | — | Row click handler |
| `searchTerm` | `string` | — | Global text filter |
| `height` | `number \| string` | — | Max scrollable height |
| `serverSide` | `ServerSideConfig` | — | Server-side pagination/sorting/filtering |
| `emptyState` | `ReactNode` | — | Empty state content |
| Prop | Type | Default | Description |
| ------------------------- | -------------------------- | -------- | --------------------------------------------------------------- |
| `data` | `TData[]` | required | Row data array |
| `columns` | `OnyxColumnDef<TData>[]` | required | Column definitions from `createTableColumns()` |
| `getRowId` | `(row: TData) => string` | required | Unique row identifier |
| `pageSize` | `number` | `10` | Rows per page (`Infinity` disables pagination) |
| `size` | `"md" \| "lg"` | `"lg"` | Density variant |
| `footer` | `DataTableFooterConfig` | — | Footer configuration (mode is derived from `selectionBehavior`) |
| `initialSorting` | `SortingState` | — | Initial sort state |
| `initialColumnVisibility` | `VisibilityState` | — | Initial column visibility |
| `draggable` | `DataTableDraggableConfig` | — | Enable drag-and-drop reordering |
| `onSelectionChange` | `(ids: string[]) => void` | — | Selection callback |
| `onRowClick` | `(row: TData) => void` | — | Row click handler |
| `getRowLabel` | `(row: TData) => string` | — | Accessible action label for clickable rows |
| `searchTerm` | `string` | — | Global text filter |
| `height` | `number \| string` | — | Max scrollable height |
| `serverSide` | `ServerSideConfig` | — | Server-side pagination/sorting/filtering |
| `emptyState` | `ReactNode` | — | Empty state content |
## Column Builder
@@ -78,5 +79,6 @@ function UsersTable({ users }: { users: User[] }) {
## Footer
The footer mode is derived automatically from `selectionBehavior`:
- **Selection footer** (when `selectionBehavior` is `"single-select"` or `"multi-select"`) — shows selection count, optional view/clear buttons, count pagination
- **Summary footer** (when `selectionBehavior` is `"no-select"` or omitted) — shows "Showing X\~Y of Z", list pagination, optional extra element
@@ -8,10 +8,18 @@ interface TableCellProps extends WithoutStyles<
children: React.ReactNode;
/** Explicit pixel width for the cell. */
width?: number;
alignment?: "left" | "center" | "right";
}
const alignmentJustifyClass = {
left: "justify-start",
center: "justify-center",
right: "justify-end",
} as const;
export default function TableCell({
width,
alignment = "left",
children,
...props
}: TableCellProps) {
@@ -24,7 +32,11 @@ export default function TableCell({
{...props}
>
<div
className={cn("tbl-cell-inner", "flex items-center overflow-hidden")}
className={cn(
"tbl-cell-inner",
"flex items-center overflow-hidden",
alignmentJustifyClass[alignment]
)}
data-size={resolvedSize}
>
{children}
@@ -87,7 +87,13 @@ export default function TableHead({
data-size={resolvedSize}
data-bottom-border={bottomBorder || undefined}
>
<div className="flex items-center gap-1">
<div
className={cn(
"flex items-center gap-1",
alignment === "right" && "justify-end",
alignment === "center" && "justify-center"
)}
>
<div className="table-head-label">
<Text
font={isSmall ? "secondary-action" : "main-ui-action"}
+14 -1
View File
@@ -7,6 +7,7 @@ import {
type CellContext,
} from "@tanstack/react-table";
import type {
ColumnAlignment,
ColumnWidth,
QualifierContentType,
OnyxQualifierColumn,
@@ -58,6 +59,7 @@ interface DataColumnConfig<TData, TValue> {
weight?: number;
/** Explicit column ID. Derived from the accessor key; required for function accessors. */
id?: string;
alignment?: ColumnAlignment;
}
// ---------------------------------------------------------------------------
@@ -75,6 +77,7 @@ interface DisplayColumnConfig<TData> {
width: ColumnWidth;
/** Enable hiding. @default true */
enableHiding?: boolean;
alignment?: ColumnAlignment;
}
// ---------------------------------------------------------------------------
@@ -188,6 +191,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
icon,
weight = 20,
id: explicitId,
alignment,
} = config;
const id = explicitId ?? (accessor as string);
@@ -210,13 +214,21 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
def,
width: { weight, minWidth: Math.max(header.length * 8 + 40, 80) },
icon,
alignment,
};
},
displayColumn(
config: DisplayColumnConfig<TData>
): OnyxDisplayColumn<TData> {
const { id, header, cell, width, enableHiding = true } = config;
const {
id,
header,
cell,
width,
enableHiding = true,
alignment,
} = config;
const def: ColumnDef<TData, any> = helper.display({
id,
@@ -232,6 +244,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
id,
def,
width,
alignment,
};
},
@@ -154,6 +154,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
selectionBehavior = "no-select",
onSelectionChange,
onRowClick,
getRowLabel,
searchTerm,
height,
serverSide,
@@ -447,6 +448,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
<TableHead
key={header.id}
width={columnWidths[header.id]}
alignment={colDef?.alignment}
sorted={
canSort ? toOnyxSortDirection(sortDir) : undefined
}
@@ -511,6 +513,22 @@ export function Table<TData>(props: DataTableProps<TData>) {
key={row.id}
sortableId={rowId}
selected={row.getIsSelected()}
data-clickable={onRowClick ? true : undefined}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
aria-label={
onRowClick ? getRowLabel?.(row.original) : undefined
}
onKeyDown={(event) => {
if (
onRowClick &&
event.currentTarget === event.target &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
onRowClick(row.original);
}
}}
onClick={() => {
if (
hasDraggable &&
@@ -589,6 +607,9 @@ export function Table<TData>(props: DataTableProps<TData>) {
<TableCell
key={cell.id}
data-column-id={cell.column.id}
alignment={
columnKindMap.get(cell.column.id)?.alignment
}
>
{flexRender(
cell.column.columnDef.cell,
@@ -128,6 +128,13 @@ table[data-variant="cards"] .tbl-row:has(:focus-visible) > td {
@apply bg-action-selection-01;
}
.tbl-row[data-clickable] {
@apply cursor-pointer;
}
.tbl-row[data-clickable]:hover > td {
@apply bg-background-tint-02;
}
/* ---- QualifierContainer ---- */
.tbl-qualifier[data-type="head"] {
@@ -38,6 +38,8 @@ export type OnyxColumnKind = "qualifier" | "data" | "display" | "actions";
// Column definitions (discriminated union on `kind`)
// ---------------------------------------------------------------------------
export type ColumnAlignment = "left" | "center" | "right";
interface OnyxColumnBase<TData> {
kind: OnyxColumnKind;
/** Stable column identifier (mirrors the TanStack column ID). */
@@ -49,6 +51,7 @@ interface OnyxColumnBase<TData> {
/** Qualifier column — leading avatar/icon/checkbox column. */
export interface OnyxQualifierColumn<TData> extends OnyxColumnBase<TData> {
kind: "qualifier";
alignment?: never;
/** Content type for body-row `<TableQualifier>`. */
content: QualifierContentType;
/** Return the icon component to render for a row (for "icon" content). */
@@ -66,6 +69,7 @@ export interface OnyxQualifierColumn<TData> extends OnyxColumnBase<TData> {
/** Data column — accessor-based column with sorting/resizing. */
export interface OnyxDataColumn<TData> extends OnyxColumnBase<TData> {
kind: "data";
alignment?: ColumnAlignment;
/** Override the sort icon for this column. */
icon?: (sorted: SortDirection) => IconFunctionComponent;
}
@@ -73,11 +77,13 @@ export interface OnyxDataColumn<TData> extends OnyxColumnBase<TData> {
/** Display column — non-accessor column with custom rendering. */
export interface OnyxDisplayColumn<TData> extends OnyxColumnBase<TData> {
kind: "display";
alignment?: ColumnAlignment;
}
/** Actions column — fixed column with visibility/sorting popovers. */
export interface OnyxActionsColumn<TData> extends OnyxColumnBase<TData> {
kind: "actions";
alignment?: never;
/** Show column visibility popover. @default true */
showColumnVisibility?: boolean;
/** Show sorting popover. @default true */
@@ -162,6 +168,7 @@ export interface DataTableProps<TData> {
onSelectionChange?: (selectedIds: string[]) => void;
/** Called when a row is clicked (replaces the default selection toggle). */
onRowClick?: (row: TData) => void;
getRowLabel?: (row: TData) => string;
/** Search term for global text filtering. When provided, rows are filtered
* to those containing the term in any accessor column value (case-insensitive). */
searchTerm?: string;