Skip to content

Commit c9adddf

Browse files
committed
feat(data-table): support array values and maxVisible in badge columns
- Extend badge accessor type to accept string[] in addition to primitives - renderBadge handles arrays with multiple Badge rendering - Add maxVisible + Popover overflow (same pattern as DescriptionCard) - Update type test: badge accessor now allows arrays - Add rendering tests for multi-badge and maxVisible in DataTable
1 parent fd55678 commit c9adddf

3 files changed

Lines changed: 128 additions & 7 deletions

File tree

packages/core/src/components/data-table/cell-renderers.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ReactNode } from "react";
22
import { Link } from "react-router";
3+
import { Popover } from "@base-ui/react/popover";
34
import { Badge } from "@/components/badge";
45
import { resolveBadgeVariant, resolveBadgeLabel } from "@/components/badge-utils";
56
import type {
@@ -121,10 +122,52 @@ function renderDate(value: unknown, options: DateCellOptions | undefined): React
121122

122123
function renderBadge(value: unknown, options: BadgeCellOptions | undefined): ReactNode {
123124
if (isEmpty(value)) return PLACEHOLDER;
124-
const key = String(value);
125-
const variant = resolveBadgeVariant(key, options);
126-
const label = resolveBadgeLabel(key, options) ?? key;
127-
return <Badge variant={variant}>{label}</Badge>;
125+
126+
const values = Array.isArray(value) ? value : [value];
127+
if (values.every((v) => isEmpty(v))) return PLACEHOLDER;
128+
129+
const maxVisible = options?.maxVisible;
130+
const nonEmpty = values.filter((v) => !isEmpty(v));
131+
const visible = maxVisible != null ? nonEmpty.slice(0, maxVisible) : nonEmpty;
132+
const overflow = maxVisible != null ? nonEmpty.slice(maxVisible) : [];
133+
134+
const renderSingleBadge = (raw: unknown, i: number) => {
135+
const key = String(raw);
136+
const variant = resolveBadgeVariant(key, options);
137+
const label = resolveBadgeLabel(key, options) ?? key;
138+
return (
139+
<Badge key={i} variant={variant}>
140+
{label}
141+
</Badge>
142+
);
143+
};
144+
145+
// Single badge without array — simple path
146+
if (visible.length === 1 && overflow.length === 0) {
147+
return renderSingleBadge(visible[0], 0);
148+
}
149+
150+
return (
151+
<div className="astw:flex astw:flex-wrap astw:gap-1">
152+
{visible.map((raw, i) => renderSingleBadge(raw, i))}
153+
{overflow.length > 0 && (
154+
<Popover.Root>
155+
<Popover.Trigger className="astw:cursor-default astw:rounded-md astw:px-2 astw:py-0.5 astw:text-xs astw:font-medium astw:text-muted-foreground astw:hover:bg-muted astw:transition-colors">
156+
+{overflow.length}
157+
</Popover.Trigger>
158+
<Popover.Portal>
159+
<Popover.Positioner sideOffset={4} side="bottom" align="start">
160+
<Popover.Popup className="astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:origin-(--transform-origin) astw:rounded-md astw:border astw:p-2 astw:shadow-md astw:animate-in astw:fade-in-0 astw:zoom-in-95 astw:data-ending-style:animate-out astw:data-ending-style:fade-out-0 astw:data-ending-style:zoom-out-95">
161+
<div className="astw:flex astw:flex-wrap astw:gap-1">
162+
{overflow.map((raw, i) => renderSingleBadge(raw, i))}
163+
</div>
164+
</Popover.Popup>
165+
</Popover.Positioner>
166+
</Popover.Portal>
167+
</Popover.Root>
168+
)}
169+
</div>
170+
);
128171
}
129172

130173
function renderLink<TRow extends Record<string, unknown>>(

packages/core/src/components/data-table/data-table.test.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,81 @@ describe("DataTable", () => {
629629
expect(container.textContent).toContain("unknown");
630630
});
631631

632+
it("renders multiple badges from array accessor", () => {
633+
const rows = [
634+
{
635+
id: "1",
636+
name: "Item",
637+
tags: ["urgent", "fragile"],
638+
status: "shipped",
639+
amount: 100,
640+
date: "2026-01-01",
641+
detailUrl: "/items/1",
642+
},
643+
];
644+
function Harness() {
645+
const table = useDataTable<(typeof rows)[number]>({
646+
columns: [
647+
{
648+
label: "Tags",
649+
type: "badge",
650+
accessor: (r) => r.tags,
651+
typeOptions: {
652+
badgeVariantMap: { urgent: "error", fragile: "warning" },
653+
},
654+
},
655+
],
656+
data: { rows },
657+
});
658+
return (
659+
<DataTable.Root value={table}>
660+
<DataTable.Table />
661+
</DataTable.Root>
662+
);
663+
}
664+
const { container } = render(<Harness />, { wrapper });
665+
expect(container.textContent).toContain("urgent");
666+
expect(container.textContent).toContain("fragile");
667+
});
668+
669+
it("renders maxVisible badges with +N overflow in DataTable", () => {
670+
const rows = [
671+
{
672+
id: "1",
673+
name: "Item",
674+
tags: ["a", "b", "c", "d"],
675+
status: "shipped",
676+
amount: 100,
677+
date: "2026-01-01",
678+
detailUrl: "/items/1",
679+
},
680+
];
681+
function Harness() {
682+
const table = useDataTable<(typeof rows)[number]>({
683+
columns: [
684+
{
685+
label: "Tags",
686+
type: "badge",
687+
accessor: (r) => r.tags,
688+
typeOptions: { maxVisible: 2 },
689+
},
690+
],
691+
data: { rows },
692+
});
693+
return (
694+
<DataTable.Root value={table}>
695+
<DataTable.Table />
696+
</DataTable.Root>
697+
);
698+
}
699+
const { container } = render(<Harness />, { wrapper });
700+
expect(container.textContent).toContain("a");
701+
expect(container.textContent).toContain("b");
702+
expect(container.textContent).toContain("+2");
703+
expect(container.textContent).not.toContain("c");
704+
expect(container.textContent).not.toContain("d");
705+
});
706+
632707
it("renders link cells with anchor when href is provided", () => {
633708
function RouterWrapper({ children }: { children: ReactNode }) {
634709
return <MemoryRouter>{wrapper({ children })}</MemoryRouter>;
@@ -730,7 +805,7 @@ describe("DataTable", () => {
730805
const moneyArr: Column<TypedRow> = { type: "money", accessor: () => [100] };
731806
// @ts-expect-error — date accessor cannot return an array
732807
const dateArr: Column<TypedRow> = { type: "date", accessor: () => [2026, 5, 13] };
733-
// @ts-expect-error — badge accessor cannot return an array
808+
// badge accessor CAN return an array (multi-badge support)
734809
const badgeArr: Column<TypedRow> = { type: "badge", accessor: () => ["a", "b"] };
735810
// @ts-expect-error — link accessor cannot return a plain object
736811
const linkObj: Column<TypedRow> = {

packages/core/src/components/data-table/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ export interface DateCellOptions {
6969
}
7070

7171
/** Options for `type: "badge"` cells. */
72-
export interface BadgeCellOptions extends BadgeOptions {}
72+
export interface BadgeCellOptions extends BadgeOptions {
73+
/** Maximum number of badges to display before showing a "+N" overflow indicator */
74+
maxVisible?: number;
75+
}
7376

7477
/** Options for `type: "link"` cells. `href` is required. */
7578
export interface LinkCellOptions<TRow extends Record<string, unknown>> {
@@ -182,7 +185,7 @@ export type ColumnTypeBranch<TRow extends Record<string, unknown>> =
182185
| {
183186
type: "badge";
184187
typeOptions?: BadgeCellOptions;
185-
accessor?: (row: TRow) => string | number | boolean | null | undefined;
188+
accessor?: (row: TRow) => string | string[] | number | boolean | null | undefined;
186189
}
187190
| {
188191
type: "link";

0 commit comments

Comments
 (0)