From fabb97097618c8e900996fb625ace832216c8be3 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 22 May 2026 16:17:50 +0900 Subject: [PATCH 1/8] feat(badge): support array values in DescriptionCard and unify badge interface - DescriptionCard badge field now accepts array values, rendering multiple badges - Extract shared BadgeVariant type and BadgeOptions interface into badge-utils.ts - DataTable's BadgeCellOptions now extends shared BadgeOptions - Add badgeLabelMap and defaultBadgeVariant to DescriptionCard FieldMeta - Deprecate BadgeVariantType in favor of shared BadgeVariant - Add resolveBadgeVariant/resolveBadgeLabel shared utilities - Add example of array badge usage in vite-app --- .../src/pages/dashboard/orders/[id]/page.tsx | 13 +++++ packages/core/src/components/badge-utils.ts | 53 +++++++++++++++++++ .../components/data-table/cell-renderers.tsx | 12 +++-- .../core/src/components/data-table/types.ts | 20 ++----- .../description-card/DescriptionCard.test.tsx | 28 ++++++++++ .../description-card/field-renderers.test.tsx | 36 +++++++++++++ .../description-card/field-renderers.tsx | 43 ++++++++++----- .../src/components/description-card/index.ts | 2 + .../src/components/description-card/types.ts | 27 ++++------ packages/core/src/index.ts | 1 + 10 files changed, 183 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/components/badge-utils.ts diff --git a/examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx b/examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx index 0ab89ee3..c8edbc0b 100644 --- a/examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx +++ b/examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx @@ -68,6 +68,7 @@ const OrderDetailPage = () => { const orderData = { orderId: id, status: "processing", + tags: ["wholesale", "priority", "fragile"], customer: "Acme Corporation", totalAmount: 3450.0, currency: "USD", @@ -109,6 +110,18 @@ const OrderDetailPage = () => { }, }, }, + { + key: "tags", + label: "Tags", + type: "badge", + meta: { + badgeVariantMap: { + wholesale: "outline-info", + priority: "error", + fragile: "warning", + }, + }, + }, { key: "customer", label: "Customer" }, { key: "totalAmount", diff --git a/packages/core/src/components/badge-utils.ts b/packages/core/src/components/badge-utils.ts new file mode 100644 index 00000000..31892326 --- /dev/null +++ b/packages/core/src/components/badge-utils.ts @@ -0,0 +1,53 @@ +import type { BadgeProps } from "./badge"; + +/** + * Variant union accepted by the app-shell `` component. + * Shared across DataTable, DescriptionCard, and any other badge consumers. + */ +export type BadgeVariant = NonNullable; + +/** + * Common options for badge rendering. + */ +export interface BadgeOptions { + /** + * Maps each value (stringified) to a Badge variant. Values not in the + * map fall back to `defaultBadgeVariant`. + */ + badgeVariantMap?: Record; + /** + * Maps each value (stringified) to a display label. Values not in the + * map render the raw value (or sentence-cased value if enabled). + */ + badgeLabelMap?: Record; + /** Variant used when the value is not in `badgeVariantMap`. Default: `"neutral"`. */ + defaultBadgeVariant?: BadgeVariant; +} + +/** + * Resolve badge variant for a given value string. + * Supports case-insensitive lookup as a fallback. + */ +export function resolveBadgeVariant( + value: string, + options: BadgeOptions | undefined, +): BadgeVariant { + const map = options?.badgeVariantMap; + if (map) { + const direct = map[value]; + if (direct) return direct; + const lower = map[value.toLowerCase()]; + if (lower) return lower; + } + return options?.defaultBadgeVariant ?? "neutral"; +} + +/** + * Resolve badge display label for a given value string. + */ +export function resolveBadgeLabel( + value: string, + options: BadgeOptions | undefined, +): string | undefined { + return options?.badgeLabelMap?.[value]; +} diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index b6598462..ec3edb06 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from "react"; import { Link } from "react-router"; import { Badge } from "@/components/badge"; +import { resolveBadgeVariant, resolveBadgeLabel } from "@/components/badge-utils"; import type { BadgeCellOptions, - BadgeVariant, Column, DateCellOptions, LinkCellOptions, @@ -79,7 +79,10 @@ function renderMoney>( // `maxDecimals` raises the cap above the currency default while keeping the // minimum at the currency default (e.g. 2 for USD). Lets a JPY column stay // at 0 decimals while a USD price-detail column shows up to 4. - const formatOptions: Intl.NumberFormatOptions = { style: "currency", currency }; + const formatOptions: Intl.NumberFormatOptions = { + style: "currency", + currency, + }; if (options?.maxDecimals != null) { formatOptions.maximumFractionDigits = options.maxDecimals; } @@ -119,9 +122,8 @@ function renderDate(value: unknown, options: DateCellOptions | undefined): React function renderBadge(value: unknown, options: BadgeCellOptions | undefined): ReactNode { if (isEmpty(value)) return PLACEHOLDER; const key = String(value); - const variant: BadgeVariant = - options?.badgeVariantMap?.[key] ?? options?.defaultBadgeVariant ?? "neutral"; - const label = options?.badgeLabelMap?.[key] ?? key; + const variant = resolveBadgeVariant(key, options); + const label = resolveBadgeLabel(key, options) ?? key; return {label}; } diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index cfe7cc3f..751d87c9 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import type { BadgeProps } from "@/components/badge"; +import type { BadgeOptions } from "@/components/badge-utils"; import type { CollectionControl, Filter, @@ -28,8 +28,7 @@ import type { */ export type ColumnCellType = "text" | "number" | "money" | "date" | "badge" | "link"; -/** Variant union accepted by the app-shell `` component. */ -export type BadgeVariant = NonNullable; +export type { BadgeVariant } from "@/components/badge-utils"; /** Options for `type: "number"` cells. */ export interface NumberCellOptions { @@ -70,20 +69,7 @@ export interface DateCellOptions { } /** Options for `type: "badge"` cells. */ -export interface BadgeCellOptions { - /** - * Maps each cell value (stringified) to a Badge variant. Values not in the - * map fall back to `defaultBadgeVariant`. - */ - badgeVariantMap?: Record; - /** - * Maps each cell value (stringified) to a display label. Values not in the - * map render the raw cell value. - */ - badgeLabelMap?: Record; - /** Variant used when the value is not in `badgeVariantMap`. Default: `"neutral"`. */ - defaultBadgeVariant?: BadgeVariant; -} +export interface BadgeCellOptions extends BadgeOptions {} /** Options for `type: "link"` cells. `href` is required. */ export interface LinkCellOptions> { diff --git a/packages/core/src/components/description-card/DescriptionCard.test.tsx b/packages/core/src/components/description-card/DescriptionCard.test.tsx index c331a16d..19d90b53 100644 --- a/packages/core/src/components/description-card/DescriptionCard.test.tsx +++ b/packages/core/src/components/description-card/DescriptionCard.test.tsx @@ -42,4 +42,32 @@ describe("DescriptionCard", () => { expect(screen.getByText("NOT_RECEIVED")).toBeDefined(); }); + + it("renders multiple badges from array value", () => { + render( + , + { wrapper }, + ); + + expect(screen.getByText("Urgent")).toBeDefined(); + expect(screen.getByText("Fragile")).toBeDefined(); + expect(screen.getByText("International")).toBeDefined(); + }); }); diff --git a/packages/core/src/components/description-card/field-renderers.test.tsx b/packages/core/src/components/description-card/field-renderers.test.tsx index 492c5823..cb25694e 100644 --- a/packages/core/src/components/description-card/field-renderers.test.tsx +++ b/packages/core/src/components/description-card/field-renderers.test.tsx @@ -53,6 +53,42 @@ describe("renderField", () => { const { container } = render(renderField(makeField({ type: "badge", value: null }))); expect(container.textContent).toBe("–"); }); + + it("renders multiple badges from array value", () => { + const { container } = render( + renderField(makeField({ type: "badge", value: ["urgent", "fragile"] })), + ); + expect(container.textContent).toContain("Urgent"); + expect(container.textContent).toContain("Fragile"); + }); + + it("renders multiple badges with variant map", () => { + const { container } = render( + renderField( + makeField({ + type: "badge", + value: ["urgent", "low"], + meta: { badgeVariantMap: { urgent: "error", low: "neutral" } }, + }), + ), + ); + expect(container.textContent).toContain("Urgent"); + expect(container.textContent).toContain("Low"); + }); + + it("renders dash for empty array", () => { + const { container } = render(renderField(makeField({ type: "badge", value: [] }))); + expect(container.textContent).toBe("–"); + }); + + it("skips null values in array", () => { + const { container } = render( + renderField(makeField({ type: "badge", value: ["active", null, "featured"] })), + ); + expect(container.textContent).toContain("Active"); + expect(container.textContent).toContain("Featured"); + expect(container.textContent).not.toContain("–"); + }); }); describe("date", () => { diff --git a/packages/core/src/components/description-card/field-renderers.tsx b/packages/core/src/components/description-card/field-renderers.tsx index 48a47bc1..6613c5ca 100644 --- a/packages/core/src/components/description-card/field-renderers.tsx +++ b/packages/core/src/components/description-card/field-renderers.tsx @@ -5,7 +5,9 @@ import { Link } from "react-router"; import { Badge } from "../badge"; import { Tooltip } from "../tooltip"; import { Copy, Check, ExternalLink } from "lucide-react"; -import type { ResolvedField, DateFormat, BadgeVariantType } from "./types"; +import type { ResolvedField, DateFormat } from "./types"; +import type { BadgeVariant } from "../badge-utils"; +import { resolveBadgeVariant, resolveBadgeLabel } from "../badge-utils"; import { useDescriptionCardT } from "./i18n"; // ============================================================================ @@ -335,25 +337,38 @@ function TextFieldRenderer({ field }: { field: ResolvedField }) { * Render a badge field */ function BadgeFieldRenderer({ field }: { field: ResolvedField }) { - if (isEmpty(field.value)) { + const values = Array.isArray(field.value) + ? (field.value as unknown[]) + : field.value != null + ? [field.value] + : []; + + if (values.every((v) => isEmpty(v))) { return {EMPTY_DASH}; } - const value = String(field.value); - const variantMap = field.meta?.badgeVariantMap || {}; + const badgeOptions = { + badgeVariantMap: field.meta?.badgeVariantMap, + badgeLabelMap: field.meta?.badgeLabelMap, + defaultBadgeVariant: field.meta?.defaultBadgeVariant ?? ("outline-neutral" as BadgeVariant), + }; const sentenceCaseBadges = field.meta?.sentenceCaseBadges ?? true; - // Try to find a matching variant (case-insensitive) - const lowerValue = value.toLowerCase(); - const variant: BadgeVariantType = - variantMap[value] || variantMap[lowerValue] || "outline-neutral"; - const displayValue = sentenceCaseBadges ? toSentenceCase(value) : value; + const badges = values + .filter((v) => !isEmpty(v)) + .map((raw, i) => { + const value = String(raw); + const variant = resolveBadgeVariant(value, badgeOptions); + const label = resolveBadgeLabel(value, badgeOptions); + const displayValue = label ?? (sentenceCaseBadges ? toSentenceCase(value) : value); + return ( + + {displayValue} + + ); + }); - return ( - - {displayValue} - - ); + return
{badges}
; } /** diff --git a/packages/core/src/components/description-card/index.ts b/packages/core/src/components/description-card/index.ts index 794f59c6..0cdc3c14 100644 --- a/packages/core/src/components/description-card/index.ts +++ b/packages/core/src/components/description-card/index.ts @@ -17,3 +17,5 @@ export type { DateFormat, BadgeVariantType, } from "./types"; + +export type { BadgeVariant } from "../badge-utils"; diff --git a/packages/core/src/components/description-card/types.ts b/packages/core/src/components/description-card/types.ts index f9f4f7e3..96fbdfca 100644 --- a/packages/core/src/components/description-card/types.ts +++ b/packages/core/src/components/description-card/types.ts @@ -1,4 +1,5 @@ import type { CSSProperties, ReactNode } from "react"; +import type { BadgeVariant } from "../badge-utils"; // ============================================================================ // FIELD TYPES @@ -10,22 +11,9 @@ import type { CSSProperties, ReactNode } from "react"; export type FieldType = "text" | "badge" | "money" | "date" | "link" | "address" | "reference"; /** - * Badge variant mapping for automatic status coloring + * @deprecated Use `BadgeVariant` from `@tailor-platform/app-shell` instead. */ -export type BadgeVariantType = - | "default" - | "success" - | "warning" - | "error" - | "neutral" - | "subtle-success" - | "subtle-warning" - | "subtle-error" - | "outline-success" - | "outline-warning" - | "outline-error" - | "outline-info" - | "outline-neutral"; +export type BadgeVariantType = BadgeVariant; /** * Behavior when a field value is empty/null/undefined @@ -48,7 +36,14 @@ export interface FieldMeta { /** Show copy button for this field */ copyable?: boolean; /** Map field values to badge variants */ - badgeVariantMap?: Record; + badgeVariantMap?: Record; + /** + * Maps each value (stringified) to a display label. Values not in the + * map render the raw value (or sentence-cased value if enabled). + */ + badgeLabelMap?: Record; + /** Variant used when the value is not in `badgeVariantMap`. Default: `"outline-neutral"`. */ + defaultBadgeVariant?: BadgeVariant; /** Render badge labels in sentence case by default; set false to keep the original value */ sentenceCaseBadges?: boolean; /** Key path to currency code in data object (for money fields) */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index daef45a1..d9335121 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -84,6 +84,7 @@ export { useOverrideBreadcrumb } from "./hooks/use-override-breadcrumb"; // Components export { Badge, badgeVariants, type BadgeProps } from "./components/badge"; +export type { BadgeVariant, BadgeOptions } from "./components/badge-utils"; export { DescriptionCard, type DescriptionCardProps } from "./components/description-card"; export { ActivityCard, From 0411476bd53f8ca9ff358ccabef47cb5272fa2b1 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 22 May 2026 16:35:20 +0900 Subject: [PATCH 2/8] feat(badge): add maxVisible with popover overflow for array badges - Add maxVisible option to FieldMeta for badge fields - Overflow badges shown in a popover on click with full Badge UI - +N trigger is a simple text element with hover highlight (no dot) - Add popover interaction test --- .../src/pages/dashboard/orders/[id]/page.tsx | 6 +- .../description-card/field-renderers.test.tsx | 56 +++++++++++++++- .../description-card/field-renderers.tsx | 66 +++++++++++++++---- .../src/components/description-card/types.ts | 2 + 4 files changed, 114 insertions(+), 16 deletions(-) diff --git a/examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx b/examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx index c8edbc0b..fb68b23e 100644 --- a/examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx +++ b/examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx @@ -68,7 +68,7 @@ const OrderDetailPage = () => { const orderData = { orderId: id, status: "processing", - tags: ["wholesale", "priority", "fragile"], + tags: ["wholesale", "priority", "fragile", "express", "insured", "tracked"], customer: "Acme Corporation", totalAmount: 3450.0, currency: "USD", @@ -115,10 +115,14 @@ const OrderDetailPage = () => { label: "Tags", type: "badge", meta: { + maxVisible: 3, badgeVariantMap: { wholesale: "outline-info", priority: "error", fragile: "warning", + express: "subtle-success", + insured: "outline-neutral", + tracked: "outline-success", }, }, }, diff --git a/packages/core/src/components/description-card/field-renderers.test.tsx b/packages/core/src/components/description-card/field-renderers.test.tsx index cb25694e..22eff877 100644 --- a/packages/core/src/components/description-card/field-renderers.test.tsx +++ b/packages/core/src/components/description-card/field-renderers.test.tsx @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { renderField } from "./field-renderers"; @@ -89,6 +90,59 @@ describe("renderField", () => { expect(container.textContent).toContain("Featured"); expect(container.textContent).not.toContain("–"); }); + + it("renders maxVisible badges with +N overflow indicator", () => { + const { container } = render( + renderField( + makeField({ + type: "badge", + value: ["a", "b", "c", "d", "e"], + meta: { maxVisible: 2 }, + }), + ), + ); + expect(container.textContent).toContain("A"); + expect(container.textContent).toContain("B"); + expect(container.textContent).toContain("+3"); + expect(container.textContent).not.toContain("C"); + expect(container.textContent).not.toContain("D"); + expect(container.textContent).not.toContain("E"); + }); + + it("does not show overflow when values fit within maxVisible", () => { + const { container } = render( + renderField( + makeField({ + type: "badge", + value: ["a", "b"], + meta: { maxVisible: 5 }, + }), + ), + ); + expect(container.textContent).toContain("A"); + expect(container.textContent).toContain("B"); + expect(container.textContent).not.toContain("+"); + }); + + it("shows overflow badges in popover on click", async () => { + const user = userEvent.setup(); + render( + renderField( + makeField({ + type: "badge", + value: ["a", "b", "c", "d"], + meta: { maxVisible: 2 }, + }), + ), + { wrapper }, + ); + + const trigger = screen.getByText("+2"); + await user.click(trigger); + + expect(screen.getByText("C")).toBeDefined(); + expect(screen.getByText("D")).toBeDefined(); + }); }); describe("date", () => { diff --git a/packages/core/src/components/description-card/field-renderers.tsx b/packages/core/src/components/description-card/field-renderers.tsx index 6613c5ca..636bdec6 100644 --- a/packages/core/src/components/description-card/field-renderers.tsx +++ b/packages/core/src/components/description-card/field-renderers.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { Link } from "react-router"; +import { Popover } from "@base-ui/react/popover"; import { Badge } from "../badge"; import { Tooltip } from "../tooltip"; import { Copy, Check, ExternalLink } from "lucide-react"; @@ -353,22 +354,59 @@ function BadgeFieldRenderer({ field }: { field: ResolvedField }) { defaultBadgeVariant: field.meta?.defaultBadgeVariant ?? ("outline-neutral" as BadgeVariant), }; const sentenceCaseBadges = field.meta?.sentenceCaseBadges ?? true; + const maxVisible = field.meta?.maxVisible; - const badges = values - .filter((v) => !isEmpty(v)) - .map((raw, i) => { - const value = String(raw); - const variant = resolveBadgeVariant(value, badgeOptions); - const label = resolveBadgeLabel(value, badgeOptions); - const displayValue = label ?? (sentenceCaseBadges ? toSentenceCase(value) : value); - return ( - - {displayValue} - - ); - }); + const resolveLabel = (raw: unknown) => { + const value = String(raw); + const label = resolveBadgeLabel(value, badgeOptions); + return label ?? (sentenceCaseBadges ? toSentenceCase(value) : value); + }; + + const nonEmptyValues = values.filter((v) => !isEmpty(v)); + const visibleValues = maxVisible != null ? nonEmptyValues.slice(0, maxVisible) : nonEmptyValues; + const overflowValues = maxVisible != null ? nonEmptyValues.slice(maxVisible) : []; + + const badges = visibleValues.map((raw, i) => { + const value = String(raw); + const variant = resolveBadgeVariant(value, badgeOptions); + const displayValue = resolveLabel(raw); + return ( + + {displayValue} + + ); + }); - return
{badges}
; + return ( +
+ {badges} + {overflowValues.length > 0 && ( + + + +{overflowValues.length} + + + + +
+ {overflowValues.map((raw, i) => { + const value = String(raw); + const variant = resolveBadgeVariant(value, badgeOptions); + const displayValue = resolveLabel(raw); + return ( + + {displayValue} + + ); + })} +
+
+
+
+
+ )} +
+ ); } /** diff --git a/packages/core/src/components/description-card/types.ts b/packages/core/src/components/description-card/types.ts index 96fbdfca..414307bd 100644 --- a/packages/core/src/components/description-card/types.ts +++ b/packages/core/src/components/description-card/types.ts @@ -46,6 +46,8 @@ export interface FieldMeta { defaultBadgeVariant?: BadgeVariant; /** Render badge labels in sentence case by default; set false to keep the original value */ sentenceCaseBadges?: boolean; + /** Maximum number of badges to display before showing a "+N" overflow indicator */ + maxVisible?: number; /** Key path to currency code in data object (for money fields) */ currencyKey?: string; /** Key path to href in data object (for link fields) */ From 9d8a226ad6d8c2fde9364611122f9197c47f05eb Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 22 May 2026 16:53:51 +0900 Subject: [PATCH 3/8] 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 --- .../components/data-table/cell-renderers.tsx | 51 +++++++++++- .../components/data-table/data-table.test.tsx | 77 ++++++++++++++++++- .../core/src/components/data-table/types.ts | 7 +- 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index ec3edb06..6b4af479 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { Link } from "react-router"; +import { Popover } from "@base-ui/react/popover"; import { Badge } from "@/components/badge"; import { resolveBadgeVariant, resolveBadgeLabel } from "@/components/badge-utils"; import type { @@ -121,10 +122,52 @@ function renderDate(value: unknown, options: DateCellOptions | undefined): React function renderBadge(value: unknown, options: BadgeCellOptions | undefined): ReactNode { if (isEmpty(value)) return PLACEHOLDER; - const key = String(value); - const variant = resolveBadgeVariant(key, options); - const label = resolveBadgeLabel(key, options) ?? key; - return {label}; + + const values = Array.isArray(value) ? value : [value]; + if (values.every((v) => isEmpty(v))) return PLACEHOLDER; + + const maxVisible = options?.maxVisible; + const nonEmpty = values.filter((v) => !isEmpty(v)); + const visible = maxVisible != null ? nonEmpty.slice(0, maxVisible) : nonEmpty; + const overflow = maxVisible != null ? nonEmpty.slice(maxVisible) : []; + + const renderSingleBadge = (raw: unknown, i: number) => { + const key = String(raw); + const variant = resolveBadgeVariant(key, options); + const label = resolveBadgeLabel(key, options) ?? key; + return ( + + {label} + + ); + }; + + // Single badge without array — simple path + if (visible.length === 1 && overflow.length === 0) { + return renderSingleBadge(visible[0], 0); + } + + return ( +
+ {visible.map((raw, i) => renderSingleBadge(raw, i))} + {overflow.length > 0 && ( + + + +{overflow.length} + + + + +
+ {overflow.map((raw, i) => renderSingleBadge(raw, i))} +
+
+
+
+
+ )} +
+ ); } function renderLink>( diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index f5e067c2..4ae975e1 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -629,6 +629,81 @@ describe("DataTable", () => { expect(container.textContent).toContain("unknown"); }); + it("renders multiple badges from array accessor", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: ["urgent", "fragile"], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags, + typeOptions: { + badgeVariantMap: { urgent: "error", fragile: "warning" }, + }, + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("urgent"); + expect(container.textContent).toContain("fragile"); + }); + + it("renders maxVisible badges with +N overflow in DataTable", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: ["a", "b", "c", "d"], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags, + typeOptions: { maxVisible: 2 }, + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("a"); + expect(container.textContent).toContain("b"); + expect(container.textContent).toContain("+2"); + expect(container.textContent).not.toContain("c"); + expect(container.textContent).not.toContain("d"); + }); + it("renders link cells with anchor when href is provided", () => { function RouterWrapper({ children }: { children: ReactNode }) { return {wrapper({ children })}; @@ -730,7 +805,7 @@ describe("DataTable", () => { const moneyArr: Column = { type: "money", accessor: () => [100] }; // @ts-expect-error — date accessor cannot return an array const dateArr: Column = { type: "date", accessor: () => [2026, 5, 13] }; - // @ts-expect-error — badge accessor cannot return an array + // badge accessor CAN return an array (multi-badge support) const badgeArr: Column = { type: "badge", accessor: () => ["a", "b"] }; // @ts-expect-error — link accessor cannot return a plain object const linkObj: Column = { diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 751d87c9..476a0ef7 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -69,7 +69,10 @@ export interface DateCellOptions { } /** Options for `type: "badge"` cells. */ -export interface BadgeCellOptions extends BadgeOptions {} +export interface BadgeCellOptions extends BadgeOptions { + /** Maximum number of badges to display before showing a "+N" overflow indicator */ + maxVisible?: number; +} /** Options for `type: "link"` cells. `href` is required. */ export interface LinkCellOptions> { @@ -182,7 +185,7 @@ export type ColumnTypeBranch> = | { type: "badge"; typeOptions?: BadgeCellOptions; - accessor?: (row: TRow) => string | number | boolean | null | undefined; + accessor?: (row: TRow) => string | string[] | number | boolean | null | undefined; } | { type: "link"; From 03757f22adcb0200cd0360cf3613e0506634f09b Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 22 May 2026 17:32:22 +0900 Subject: [PATCH 4/8] refactor: consolidate badge rendering into BadgeList component - Merge badge-utils.ts into badge-list.tsx as single source of truth - Remove default render from inferColumns() to fix type renderer priority - Add renderText fallback for boolean/Date/object values - Add openOnHover to BadgeList overflow popover - Add tags badge column to DataTable demo (multiple badges + maxVisible) - Convert status column from custom render to type: badge - Add comprehensive tests for BadgeList, renderText fallback, and field-helpers --- .../src/modules/pages/data-table-demo.tsx | 33 +++- .../nextjs-app/src/modules/pages/mock-data.ts | 21 +++ .../core/src/components/badge-list.test.tsx | 137 +++++++++++++++ packages/core/src/components/badge-list.tsx | 152 +++++++++++++++++ packages/core/src/components/badge-utils.ts | 53 ------ .../components/data-table/cell-renderers.tsx | 54 +----- .../components/data-table/data-table.test.tsx | 157 +++++++++++++++--- .../data-table/field-helpers.test.ts | 18 +- .../components/data-table/field-helpers.ts | 12 -- .../core/src/components/data-table/types.ts | 4 +- .../description-card/field-renderers.tsx | 61 ++----- .../src/components/description-card/index.ts | 2 +- .../src/components/description-card/types.ts | 2 +- packages/core/src/index.ts | 2 +- 14 files changed, 496 insertions(+), 212 deletions(-) create mode 100644 packages/core/src/components/badge-list.test.tsx create mode 100644 packages/core/src/components/badge-list.tsx delete mode 100644 packages/core/src/components/badge-utils.ts diff --git a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx index 61b22100..3e83d371 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -1,6 +1,5 @@ import { defineResource, - Badge, DataTable, useDataTable, useCollectionVariables, @@ -44,6 +43,7 @@ const productMetadata = { required: true, enumValues: ["Active", "Draft", "Archived"], }, + { name: "tags", type: "string", required: false }, ], } as const; @@ -91,14 +91,29 @@ const productColumns = [ }), column({ ...infer("status"), - render: (row) => { - const variant = - row.status === "Active" - ? ("success" as const) - : row.status === "Draft" - ? ("outline-warning" as const) - : ("neutral" as const); - return {row.status}; + type: "badge", + accessor: (row) => row.status, + typeOptions: { + badgeVariantMap: { + Active: "success", + Draft: "outline-warning", + Archived: "neutral", + }, + }, + }), + column({ + ...infer("tags"), + type: "badge", + accessor: (row) => row.tags, + typeOptions: { + badgeVariantMap: { + Premium: "warning", + Ergonomic: "success", + Office: "outline-info", + Wireless: "outline-neutral", + }, + defaultBadgeVariant: "neutral", + maxVisible: 2, }, }), ]; diff --git a/examples/nextjs-app/src/modules/pages/mock-data.ts b/examples/nextjs-app/src/modules/pages/mock-data.ts index 8d0fec1d..4e8bc156 100644 --- a/examples/nextjs-app/src/modules/pages/mock-data.ts +++ b/examples/nextjs-app/src/modules/pages/mock-data.ts @@ -9,6 +9,7 @@ export type Product = { price: number; stock: number; status: "Active" | "Draft" | "Archived"; + tags: string[]; }; export const allProducts: Product[] = [ @@ -24,6 +25,7 @@ export const allProducts: Product[] = [ price: 499.99, stock: 42, status: "Active", + tags: ["Ergonomic", "Office", "Best Seller"], }, { id: "p-002", @@ -37,6 +39,7 @@ export const allProducts: Product[] = [ price: 899.0, stock: 15, status: "Active", + tags: ["Motorized", "Office", "Adjustable", "Premium"], }, { id: "p-003", @@ -49,6 +52,7 @@ export const allProducts: Product[] = [ price: 159.99, stock: 230, status: "Active", + tags: ["Mechanical", "RGB", "Hot-swap"], }, { id: "p-004", @@ -62,6 +66,7 @@ export const allProducts: Product[] = [ price: 79.99, stock: 0, status: "Draft", + tags: ["USB-C", "Portable"], }, { id: "p-005", @@ -75,6 +80,7 @@ export const allProducts: Product[] = [ price: 129.0, stock: 57, status: "Active", + tags: ["Ergonomic", "Adjustable"], }, { id: "p-006", @@ -87,6 +93,7 @@ export const allProducts: Product[] = [ price: 89.99, stock: 120, status: "Archived", + tags: ["HD", "Autofocus"], }, { id: "p-007", @@ -100,6 +107,7 @@ export const allProducts: Product[] = [ price: 45.0, stock: 88, status: "Active", + tags: ["LED", "USB Charging"], }, { id: "p-008", @@ -113,6 +121,7 @@ export const allProducts: Product[] = [ price: 29.99, stock: 200, status: "Draft", + tags: ["Cable Management"], }, { id: "p-009", @@ -126,6 +135,7 @@ export const allProducts: Product[] = [ price: 349.99, stock: 64, status: "Active", + tags: ["ANC", "Bluetooth", "Wireless", "Premium"], }, { id: "p-010", @@ -138,6 +148,7 @@ export const allProducts: Product[] = [ price: 59.99, stock: 110, status: "Active", + tags: ["Portable", "Foldable"], }, { id: "p-011", @@ -151,6 +162,7 @@ export const allProducts: Product[] = [ price: 69.99, stock: 180, status: "Active", + tags: ["Ergonomic", "Wireless", "Rechargeable"], }, { id: "p-012", @@ -164,6 +176,7 @@ export const allProducts: Product[] = [ price: 34.99, stock: 300, status: "Active", + tags: ["Eco-friendly", "Non-slip"], }, { id: "p-013", @@ -177,6 +190,7 @@ export const allProducts: Product[] = [ price: 249.0, stock: 22, status: "Draft", + tags: ["Adjustable", "Heavy-duty"], }, { id: "p-014", @@ -189,6 +203,7 @@ export const allProducts: Product[] = [ price: 24.99, stock: 500, status: "Active", + tags: ["Surge Protection"], }, { id: "p-015", @@ -202,6 +217,7 @@ export const allProducts: Product[] = [ price: 189.0, stock: 18, status: "Active", + tags: ["Magnetic", "Office"], }, { id: "p-016", @@ -215,6 +231,7 @@ export const allProducts: Product[] = [ price: 129.99, stock: 75, status: "Active", + tags: ["USB", "Cardioid", "Streaming"], }, { id: "p-017", @@ -228,6 +245,7 @@ export const allProducts: Product[] = [ price: 349.0, stock: 8, status: "Draft", + tags: ["Lockable", "Heavy-duty", "Office"], }, { id: "p-018", @@ -240,6 +258,7 @@ export const allProducts: Product[] = [ price: 14.99, stock: 600, status: "Active", + tags: ["4K", "Braided"], }, { id: "p-019", @@ -253,6 +272,7 @@ export const allProducts: Product[] = [ price: 79.0, stock: 45, status: "Archived", + tags: ["Ergonomic", "Adjustable"], }, { id: "p-020", @@ -266,6 +286,7 @@ export const allProducts: Product[] = [ price: 199.99, stock: 33, status: "Active", + tags: ["Thunderbolt", "USB-C", "4K", "Ethernet", "Premium"], }, ]; diff --git a/packages/core/src/components/badge-list.test.tsx b/packages/core/src/components/badge-list.test.tsx new file mode 100644 index 00000000..d648f80e --- /dev/null +++ b/packages/core/src/components/badge-list.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createAppShellWrapper } from "../../tests/test-utils"; +import { BadgeList, resolveBadgeVariant, resolveBadgeLabel } from "./badge-list"; + +afterEach(() => { + cleanup(); +}); + +const wrapper = createAppShellWrapper(); + +describe("resolveBadgeVariant", () => { + it("returns variant from map", () => { + expect(resolveBadgeVariant("urgent", { badgeVariantMap: { urgent: "error" } })).toBe("error"); + }); + + it("falls back to case-insensitive lookup", () => { + expect(resolveBadgeVariant("URGENT", { badgeVariantMap: { urgent: "error" } })).toBe("error"); + }); + + it("returns defaultBadgeVariant when not in map", () => { + expect(resolveBadgeVariant("unknown", { defaultBadgeVariant: "warning" })).toBe("warning"); + }); + + it("returns 'neutral' when no options", () => { + expect(resolveBadgeVariant("anything", undefined)).toBe("neutral"); + }); +}); + +describe("resolveBadgeLabel", () => { + it("returns label from map", () => { + expect(resolveBadgeLabel("draft", { badgeLabelMap: { draft: "Draft Order" } })).toBe( + "Draft Order", + ); + }); + + it("returns undefined when not in map", () => { + expect(resolveBadgeLabel("missing", { badgeLabelMap: { draft: "Draft" } })).toBeUndefined(); + }); + + it("returns undefined when no options", () => { + expect(resolveBadgeLabel("anything", undefined)).toBeUndefined(); + }); +}); + +describe("BadgeList", () => { + it("renders a single badge for a string value", () => { + const { container } = render(, { wrapper }); + expect(container.textContent).toBe("active"); + }); + + it("renders multiple badges from array", () => { + const { container } = render(, { + wrapper, + }); + expect(container.textContent).toContain("a"); + expect(container.textContent).toContain("b"); + expect(container.textContent).toContain("c"); + }); + + it("returns null for empty array", () => { + const { container } = render(, { wrapper }); + expect(container.textContent).toBe(""); + }); + + it("returns null for null value", () => { + const { container } = render(, { wrapper }); + expect(container.textContent).toBe(""); + }); + + it("filters out null and empty string values", () => { + const { container } = render(, { + wrapper, + }); + expect(container.textContent).toContain("a"); + expect(container.textContent).toContain("b"); + }); + + it("applies badgeLabelMap for display labels", () => { + const { container } = render( + , + { wrapper }, + ); + expect(container.textContent).toBe("Draft Order"); + }); + + it("uses custom resolveLabel function", () => { + const { container } = render( + v.toUpperCase()} />, + { wrapper }, + ); + expect(container.textContent).toContain("HELLO_WORLD"); + }); + + it("renders maxVisible badges with +N overflow", () => { + const { container } = render(, { + wrapper, + }); + expect(container.textContent).toContain("a"); + expect(container.textContent).toContain("b"); + expect(container.textContent).toContain("+3"); + expect(container.textContent).not.toContain("c"); + expect(container.textContent).not.toContain("d"); + expect(container.textContent).not.toContain("e"); + }); + + it("does not show overflow when count is within maxVisible", () => { + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("a"); + expect(container.textContent).toContain("b"); + expect(container.textContent).not.toContain("+"); + }); + + it("shows overflow badges in popover on hover", async () => { + const user = userEvent.setup(); + render(, { + wrapper, + }); + + const trigger = screen.getByText("+2"); + await user.hover(trigger); + + await waitFor(() => { + expect(screen.getByText("c")).toBeDefined(); + expect(screen.getByText("d")).toBeDefined(); + }); + }); + + it("applies badgeClassName to each badge", () => { + const { container } = render(, { + wrapper, + }); + const badge = container.querySelector("[class*='w-fit']"); + expect(badge).not.toBeNull(); + }); +}); diff --git a/packages/core/src/components/badge-list.tsx b/packages/core/src/components/badge-list.tsx new file mode 100644 index 00000000..56c9db20 --- /dev/null +++ b/packages/core/src/components/badge-list.tsx @@ -0,0 +1,152 @@ +"use client"; + +import * as React from "react"; +import { Popover } from "@base-ui/react/popover"; +import { Badge } from "./badge"; +import type { BadgeProps } from "./badge"; + +// ============================================================================ +// BADGE UTILITIES (types & helpers) +// ============================================================================ + +/** + * Variant union accepted by the app-shell `` component. + * Shared across DataTable, DescriptionCard, and any other badge consumers. + */ +export type BadgeVariant = NonNullable; + +/** + * Common options for badge rendering. + */ +export interface BadgeOptions { + /** + * Maps each value (stringified) to a Badge variant. Values not in the + * map fall back to `defaultBadgeVariant`. + */ + badgeVariantMap?: Record; + /** + * Maps each value (stringified) to a display label. Values not in the + * map render the raw value (or sentence-cased value if enabled). + */ + badgeLabelMap?: Record; + /** Variant used when the value is not in `badgeVariantMap`. Default: `"neutral"`. */ + defaultBadgeVariant?: BadgeVariant; +} + +/** + * Resolve badge variant for a given value string. + * Supports case-insensitive lookup as a fallback. + */ +export function resolveBadgeVariant( + value: string, + options: BadgeOptions | undefined, +): BadgeVariant { + const map = options?.badgeVariantMap; + if (map) { + const direct = map[value]; + if (direct) return direct; + const lower = map[value.toLowerCase()]; + if (lower) return lower; + } + return options?.defaultBadgeVariant ?? "neutral"; +} + +/** + * Resolve badge display label for a given value string. + */ +export function resolveBadgeLabel( + value: string, + options: BadgeOptions | undefined, +): string | undefined { + return options?.badgeLabelMap?.[value]; +} + +// ============================================================================ +// BADGE LIST COMPONENT +// ============================================================================ + +/** + * Props for the BadgeList component. + */ +export interface BadgeListProps { + /** Raw value(s) to render as badges. Accepts a single value or an array. */ + value: unknown; + /** Badge options (variant map, label map, default variant). */ + options?: BadgeOptions; + /** Maximum number of visible badges before showing "+N" overflow. */ + maxVisible?: number; + /** + * Custom label resolver. Called for each value to determine the display text. + * When omitted, falls back to `badgeLabelMap` lookup then raw string value. + */ + resolveLabel?: (value: string) => string; + /** Additional className applied to each Badge element. */ + badgeClassName?: string; +} + +/** + * Shared badge list renderer used by DataTable and DescriptionCard. + * Renders one or more Badge components with optional overflow popover. + */ +export function BadgeList({ + value, + options, + maxVisible, + resolveLabel: resolveLabelProp, + badgeClassName, +}: BadgeListProps): React.ReactNode { + const values = Array.isArray(value) ? value : [value]; + const nonEmpty = values.filter((v) => v != null && v !== ""); + + if (nonEmpty.length === 0) return null; + + const getLabel = (raw: unknown) => { + const str = String(raw); + if (resolveLabelProp) return resolveLabelProp(str); + return resolveBadgeLabel(str, options) ?? str; + }; + + const visible = maxVisible != null ? nonEmpty.slice(0, maxVisible) : nonEmpty; + const overflow = maxVisible != null ? nonEmpty.slice(maxVisible) : []; + + const renderSingleBadge = (raw: unknown, i: number) => { + const str = String(raw); + const variant = resolveBadgeVariant(str, options); + const label = getLabel(raw); + return ( + + {label} + + ); + }; + + // Single badge — no wrapper div needed + if (visible.length === 1 && overflow.length === 0) { + return renderSingleBadge(visible[0], 0); + } + + return ( +
+ {visible.map((raw, i) => renderSingleBadge(raw, i))} + {overflow.length > 0 && ( + + + +{overflow.length} + + + + +
+ {overflow.map((raw, i) => renderSingleBadge(raw, i))} +
+
+
+
+
+ )} +
+ ); +} diff --git a/packages/core/src/components/badge-utils.ts b/packages/core/src/components/badge-utils.ts deleted file mode 100644 index 31892326..00000000 --- a/packages/core/src/components/badge-utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { BadgeProps } from "./badge"; - -/** - * Variant union accepted by the app-shell `` component. - * Shared across DataTable, DescriptionCard, and any other badge consumers. - */ -export type BadgeVariant = NonNullable; - -/** - * Common options for badge rendering. - */ -export interface BadgeOptions { - /** - * Maps each value (stringified) to a Badge variant. Values not in the - * map fall back to `defaultBadgeVariant`. - */ - badgeVariantMap?: Record; - /** - * Maps each value (stringified) to a display label. Values not in the - * map render the raw value (or sentence-cased value if enabled). - */ - badgeLabelMap?: Record; - /** Variant used when the value is not in `badgeVariantMap`. Default: `"neutral"`. */ - defaultBadgeVariant?: BadgeVariant; -} - -/** - * Resolve badge variant for a given value string. - * Supports case-insensitive lookup as a fallback. - */ -export function resolveBadgeVariant( - value: string, - options: BadgeOptions | undefined, -): BadgeVariant { - const map = options?.badgeVariantMap; - if (map) { - const direct = map[value]; - if (direct) return direct; - const lower = map[value.toLowerCase()]; - if (lower) return lower; - } - return options?.defaultBadgeVariant ?? "neutral"; -} - -/** - * Resolve badge display label for a given value string. - */ -export function resolveBadgeLabel( - value: string, - options: BadgeOptions | undefined, -): string | undefined { - return options?.badgeLabelMap?.[value]; -} diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index 6b4af479..23e39ca5 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -1,8 +1,6 @@ import type { ReactNode } from "react"; import { Link } from "react-router"; -import { Popover } from "@base-ui/react/popover"; -import { Badge } from "@/components/badge"; -import { resolveBadgeVariant, resolveBadgeLabel } from "@/components/badge-utils"; +import { BadgeList } from "@/components/badge-list"; import type { BadgeCellOptions, Column, @@ -51,6 +49,9 @@ function toDate(value: unknown): Date | null { function renderText(value: unknown): ReactNode { if (isEmpty(value)) return PLACEHOLDER; + if (typeof value === "boolean") return value ? "✓" : "✗"; + if (value instanceof Date) return value.toLocaleDateString(); + if (typeof value === "object") return JSON.stringify(value); return String(value); } @@ -122,52 +123,7 @@ function renderDate(value: unknown, options: DateCellOptions | undefined): React function renderBadge(value: unknown, options: BadgeCellOptions | undefined): ReactNode { if (isEmpty(value)) return PLACEHOLDER; - - const values = Array.isArray(value) ? value : [value]; - if (values.every((v) => isEmpty(v))) return PLACEHOLDER; - - const maxVisible = options?.maxVisible; - const nonEmpty = values.filter((v) => !isEmpty(v)); - const visible = maxVisible != null ? nonEmpty.slice(0, maxVisible) : nonEmpty; - const overflow = maxVisible != null ? nonEmpty.slice(maxVisible) : []; - - const renderSingleBadge = (raw: unknown, i: number) => { - const key = String(raw); - const variant = resolveBadgeVariant(key, options); - const label = resolveBadgeLabel(key, options) ?? key; - return ( - - {label} - - ); - }; - - // Single badge without array — simple path - if (visible.length === 1 && overflow.length === 0) { - return renderSingleBadge(visible[0], 0); - } - - return ( -
- {visible.map((raw, i) => renderSingleBadge(raw, i))} - {overflow.length > 0 && ( - - - +{overflow.length} - - - - -
- {overflow.map((raw, i) => renderSingleBadge(raw, i))} -
-
-
-
-
- )} -
- ); + return ; } function renderLink>( diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index 4ae975e1..ee0fcf8f 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -268,14 +268,18 @@ describe("DataTable", () => { ]; it("applies text-right to the header cell when align=right", () => { - const { container } = render(, { wrapper }); + const { container } = render(, { + wrapper, + }); const heads = container.querySelectorAll('[data-slot="data-table-header"] th'); expect(heads[0]?.className).not.toContain("text-right"); expect(heads[1]?.className).toContain("text-right"); }); it("applies text-right to body cells when align=right", () => { - const { container } = render(, { wrapper }); + const { container } = render(, { + wrapper, + }); const firstRow = container.querySelector('[data-slot="data-table-row"]'); const cells = firstRow?.querySelectorAll('[data-slot="data-table-cell"]') ?? []; expect(cells[0]?.className).not.toContain("text-right"); @@ -308,7 +312,10 @@ describe("DataTable", () => { const numRows: NumRow[] = [{ id: "1", count: 42 }]; const cols: Column[] = [{ label: "Count", type: "number", accessor: (r) => r.count }]; function Harness() { - const table = useDataTable({ columns: cols, data: { rows: numRows } }); + const table = useDataTable({ + columns: cols, + data: { rows: numRows }, + }); return ( @@ -334,7 +341,10 @@ describe("DataTable", () => { }, ]; function Harness() { - const table = useDataTable({ columns: cols, data: { rows: moneyRows } }); + const table = useDataTable({ + columns: cols, + data: { rows: moneyRows }, + }); return ( @@ -355,7 +365,12 @@ describe("DataTable", () => { { label: "Text", type: "text", accessor: (r) => r.v }, { label: "Date", type: "date", accessor: (r) => r.v }, { label: "Badge", type: "badge", accessor: (r) => r.v }, - { label: "Link", type: "link", accessor: (r) => r.v, typeOptions: { href: () => "/x" } }, + { + label: "Link", + type: "link", + accessor: (r) => r.v, + typeOptions: { href: () => "/x" }, + }, ]; function Harness() { const table = useDataTable({ columns: cols, data: { rows } }); @@ -377,10 +392,18 @@ describe("DataTable", () => { type NumRow = { id: string; count: number }; const numRows: NumRow[] = [{ id: "1", count: 42 }]; const cols: Column[] = [ - { label: "Count", type: "number", accessor: (r) => r.count, align: "left" }, + { + label: "Count", + type: "number", + accessor: (r) => r.count, + align: "left", + }, ]; function Harness() { - const table = useDataTable({ columns: cols, data: { rows: numRows } }); + const table = useDataTable({ + columns: cols, + data: { rows: numRows }, + }); return ( @@ -407,7 +430,9 @@ describe("DataTable", () => { }, { label: "Status", render: (row) => row.status }, ]; - const { container } = render(, { wrapper }); + const { container } = render(, { + wrapper, + }); const firstRow = container.querySelector('[data-slot="data-table-row"]'); const cells = firstRow?.querySelectorAll('[data-slot="data-table-cell"]') ?? []; expect(cells[0]?.className).toContain("truncate"); @@ -424,7 +449,9 @@ describe("DataTable", () => { truncate: true, }, ]; - const { container } = render(, { wrapper }); + const { container } = render(, { + wrapper, + }); const cells = container.querySelectorAll('[data-slot="data-table-cell"]'); expect(isTooltipWired(cells[0] ?? null)).toBe(true); expect(isTooltipWired(cells[1] ?? null)).toBe(true); @@ -442,7 +469,10 @@ describe("DataTable", () => { }, ]; function Harness() { - const table = useDataTable({ columns: cols, data: { rows: numRows } }); + const table = useDataTable({ + columns: cols, + data: { rows: numRows }, + }); return ( @@ -466,7 +496,10 @@ describe("DataTable", () => { }, ]; function Harness() { - const table = useDataTable({ columns: cols, data: { rows: objRows } }); + const table = useDataTable({ + columns: cols, + data: { rows: objRows }, + }); return ( @@ -488,7 +521,9 @@ describe("DataTable", () => { truncate: true, }, ]; - const { container } = render(, { wrapper }); + const { container } = render(, { + wrapper, + }); const cell = container.querySelector('[data-slot="data-table-cell"]'); expect(cell?.className).toContain("truncate"); expect(isTooltipWired(cell)).toBe(false); @@ -506,7 +541,9 @@ describe("DataTable", () => { truncate: true, }, ]; - const { container } = render(, { wrapper }); + const { container } = render(, { + wrapper, + }); const cells = container.querySelectorAll('[data-slot="data-table-cell"]'); expect(isTooltipWired(cells[0] ?? null)).toBe(true); expect(isTooltipWired(cells[1] ?? null)).toBe(true); @@ -737,6 +774,65 @@ describe("DataTable", () => { expect(container.textContent).toContain("Order 1"); }); + it("renders boolean as ✓/✗ when no type is set", () => { + type BoolRow = { id: string; active: boolean }; + const rows: BoolRow[] = [ + { id: "1", active: true }, + { id: "2", active: false }, + ]; + function Harness() { + const table = useDataTable({ + columns: [{ id: "active", label: "Active", accessor: (r) => r.active }], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("✓"); + expect(container.textContent).toContain("✗"); + }); + + it("renders Date as locale string when no type is set", () => { + type DateRow = { id: string; createdAt: Date }; + const date = new Date("2026-03-15T00:00:00Z"); + const rows: DateRow[] = [{ id: "1", createdAt: date }]; + function Harness() { + const table = useDataTable({ + columns: [{ id: "createdAt", label: "Created", accessor: (r) => r.createdAt }], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain(date.toLocaleDateString()); + }); + + it("renders object as JSON when no type is set", () => { + type ObjRow = { id: string; meta: Record }; + const rows: ObjRow[] = [{ id: "1", meta: { foo: "bar" } }]; + function Harness() { + const table = useDataTable({ + columns: [{ id: "meta", label: "Meta", accessor: (r) => r.meta }], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain('{"foo":"bar"}'); + }); + it("explicit render overrides type renderer", () => { const { container } = renderTypedTable([ { @@ -796,17 +892,35 @@ describe("DataTable", () => { // `render` (and drop `type`) when the cell value isn't a primitive. // @ts-expect-error — text accessor cannot return an array - const textArr: Column = { type: "text", accessor: () => [1, 2] }; + const textArr: Column = { + type: "text", + accessor: () => [1, 2], + }; // @ts-expect-error — text accessor cannot return a plain object - const textObj: Column = { type: "text", accessor: () => ({ a: 1 }) }; + const textObj: Column = { + type: "text", + accessor: () => ({ a: 1 }), + }; // @ts-expect-error — number accessor cannot return an object - const numberObj: Column = { type: "number", accessor: () => ({ value: 1 }) }; + const numberObj: Column = { + type: "number", + accessor: () => ({ value: 1 }), + }; // @ts-expect-error — money accessor cannot return an array - const moneyArr: Column = { type: "money", accessor: () => [100] }; + const moneyArr: Column = { + type: "money", + accessor: () => [100], + }; // @ts-expect-error — date accessor cannot return an array - const dateArr: Column = { type: "date", accessor: () => [2026, 5, 13] }; + const dateArr: Column = { + type: "date", + accessor: () => [2026, 5, 13], + }; // badge accessor CAN return an array (multi-badge support) - const badgeArr: Column = { type: "badge", accessor: () => ["a", "b"] }; + const badgeArr: Column = { + type: "badge", + accessor: () => ["a", "b"], + }; // @ts-expect-error — link accessor cannot return a plain object const linkObj: Column = { type: "link", @@ -815,7 +929,10 @@ describe("DataTable", () => { }; // Date is allowed on the date branch (and only there). - const dateOk: Column = { type: "date", accessor: () => new Date() }; + const dateOk: Column = { + type: "date", + accessor: () => new Date(), + }; // The untyped branch keeps `unknown` — callers escape the built-in // renderer entirely by providing `render`, so any return is fine. const untypedAny: Column = { diff --git a/packages/core/src/components/data-table/field-helpers.test.ts b/packages/core/src/components/data-table/field-helpers.test.ts index 5dc12f71..079f76c4 100644 --- a/packages/core/src/components/data-table/field-helpers.test.ts +++ b/packages/core/src/components/data-table/field-helpers.test.ts @@ -88,7 +88,7 @@ describe("inferColumns()", () => { const titleOpts = infer("title"); expect(titleOpts.label).toBe("title"); expect(titleOpts.sort).toEqual({ field: "title", type: "string" }); - expect(typeof titleOpts.render).toBe("function"); + expect(titleOpts.render).toBeUndefined(); const titleCol = column(infer("title")); expect(titleCol.label).toBe("title"); @@ -248,7 +248,7 @@ describe("inferColumns() with metadata", () => { expect(titleOpts.label).toBe("title"); expect(titleOpts.sort).toEqual({ field: "title", type: "string" }); expect(titleOpts.filter).toEqual({ field: "title", type: "string" }); - expect(typeof titleOpts.render).toBe("function"); + expect(titleOpts.render).toBeUndefined(); }); it("auto-detects enum options", () => { @@ -301,21 +301,11 @@ describe("inferColumns() with metadata", () => { expect(opts.filter).toBeUndefined(); }); - it("generates default render function", () => { + it("does not set a default render function", () => { const infer = inferColumns(testMetadata.task); const opts = infer("title"); - const testRow = { - id: "1", - title: "Test Task", - status: "todo", - dueDate: "2024-01-01", - count: 5, - isActive: true, - tags: ["a"], - }; - - expect(opts.render!(testRow)).toBe("Test Task"); + expect(opts.render).toBeUndefined(); }); it("throws for non-existent field", () => { diff --git a/packages/core/src/components/data-table/field-helpers.ts b/packages/core/src/components/data-table/field-helpers.ts index b6ad23d7..e6cfcb26 100644 --- a/packages/core/src/components/data-table/field-helpers.ts +++ b/packages/core/src/components/data-table/field-helpers.ts @@ -1,4 +1,3 @@ -import type { ReactNode } from "react"; import type { FilterConfig, SortConfig, TableFieldName, TableMetadata } from "@/types/collection"; import { fieldTypeToFilterConfig, fieldTypeToSortConfig } from "@/types/collection"; import type { Column, ColumnBase, MetadataFieldOptions } from "./types"; @@ -21,14 +20,6 @@ export function column>(options: Column) => formatValue(row[fieldName])) as ( - row: TRow, - ) => ReactNode, width: columnOptions?.width, sort, filter, diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 476a0ef7..91b1478d 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import type { BadgeOptions } from "@/components/badge-utils"; +import type { BadgeOptions } from "@/components/badge-list"; import type { CollectionControl, Filter, @@ -28,7 +28,7 @@ import type { */ export type ColumnCellType = "text" | "number" | "money" | "date" | "badge" | "link"; -export type { BadgeVariant } from "@/components/badge-utils"; +export type { BadgeVariant } from "@/components/badge-list"; /** Options for `type: "number"` cells. */ export interface NumberCellOptions { diff --git a/packages/core/src/components/description-card/field-renderers.tsx b/packages/core/src/components/description-card/field-renderers.tsx index 636bdec6..6f1030d6 100644 --- a/packages/core/src/components/description-card/field-renderers.tsx +++ b/packages/core/src/components/description-card/field-renderers.tsx @@ -2,13 +2,12 @@ import * as React from "react"; import { Link } from "react-router"; -import { Popover } from "@base-ui/react/popover"; -import { Badge } from "../badge"; +import { BadgeList } from "../badge-list"; import { Tooltip } from "../tooltip"; import { Copy, Check, ExternalLink } from "lucide-react"; import type { ResolvedField, DateFormat } from "./types"; -import type { BadgeVariant } from "../badge-utils"; -import { resolveBadgeVariant, resolveBadgeLabel } from "../badge-utils"; +import type { BadgeVariant } from "../badge-list"; +import { resolveBadgeLabel } from "../badge-list"; import { useDescriptionCardT } from "./i18n"; // ============================================================================ @@ -354,58 +353,20 @@ function BadgeFieldRenderer({ field }: { field: ResolvedField }) { defaultBadgeVariant: field.meta?.defaultBadgeVariant ?? ("outline-neutral" as BadgeVariant), }; const sentenceCaseBadges = field.meta?.sentenceCaseBadges ?? true; - const maxVisible = field.meta?.maxVisible; - const resolveLabel = (raw: unknown) => { - const value = String(raw); + const resolveLabel = (value: string) => { const label = resolveBadgeLabel(value, badgeOptions); return label ?? (sentenceCaseBadges ? toSentenceCase(value) : value); }; - const nonEmptyValues = values.filter((v) => !isEmpty(v)); - const visibleValues = maxVisible != null ? nonEmptyValues.slice(0, maxVisible) : nonEmptyValues; - const overflowValues = maxVisible != null ? nonEmptyValues.slice(maxVisible) : []; - - const badges = visibleValues.map((raw, i) => { - const value = String(raw); - const variant = resolveBadgeVariant(value, badgeOptions); - const displayValue = resolveLabel(raw); - return ( - - {displayValue} - - ); - }); - return ( -
- {badges} - {overflowValues.length > 0 && ( - - - +{overflowValues.length} - - - - -
- {overflowValues.map((raw, i) => { - const value = String(raw); - const variant = resolveBadgeVariant(value, badgeOptions); - const displayValue = resolveLabel(raw); - return ( - - {displayValue} - - ); - })} -
-
-
-
-
- )} -
+ ); } diff --git a/packages/core/src/components/description-card/index.ts b/packages/core/src/components/description-card/index.ts index 0cdc3c14..4443b5fe 100644 --- a/packages/core/src/components/description-card/index.ts +++ b/packages/core/src/components/description-card/index.ts @@ -18,4 +18,4 @@ export type { BadgeVariantType, } from "./types"; -export type { BadgeVariant } from "../badge-utils"; +export type { BadgeVariant } from "../badge-list"; diff --git a/packages/core/src/components/description-card/types.ts b/packages/core/src/components/description-card/types.ts index 414307bd..b84a55b0 100644 --- a/packages/core/src/components/description-card/types.ts +++ b/packages/core/src/components/description-card/types.ts @@ -1,5 +1,5 @@ import type { CSSProperties, ReactNode } from "react"; -import type { BadgeVariant } from "../badge-utils"; +import type { BadgeVariant } from "../badge-list"; // ============================================================================ // FIELD TYPES diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d9335121..a1a2cee7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -84,7 +84,7 @@ export { useOverrideBreadcrumb } from "./hooks/use-override-breadcrumb"; // Components export { Badge, badgeVariants, type BadgeProps } from "./components/badge"; -export type { BadgeVariant, BadgeOptions } from "./components/badge-utils"; +export type { BadgeVariant, BadgeOptions } from "./components/badge-list"; export { DescriptionCard, type DescriptionCardProps } from "./components/description-card"; export { ActivityCard, From c11879e79986904bd9ce1eeff98bd144adbdde01 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 22 May 2026 18:20:11 +0900 Subject: [PATCH 5/8] fix(badge): handle empty array placeholder and unify default variant - renderBadge now returns placeholder for empty arrays (consistent with DescriptionCard) - Align default badge variant to 'outline-neutral' across DataTable and DescriptionCard - Remove redundant BadgeVariant override in DescriptionCard field-renderers - Add changeset --- .changeset/badge-array-support.md | 39 +++++++++++++++++++ .../core/src/components/badge-list.test.tsx | 4 +- packages/core/src/components/badge-list.tsx | 4 +- .../components/data-table/cell-renderers.tsx | 2 +- .../components/data-table/data-table.test.tsx | 33 ++++++++++++++++ .../description-card/field-renderers.tsx | 3 +- .../src/components/description-card/types.ts | 2 +- 7 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 .changeset/badge-array-support.md diff --git a/.changeset/badge-array-support.md b/.changeset/badge-array-support.md new file mode 100644 index 00000000..ca9c98ec --- /dev/null +++ b/.changeset/badge-array-support.md @@ -0,0 +1,39 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add array badge support to DataTable and DescriptionCard with shared `BadgeList` rendering and overflow popover. + +```tsx +// DataTable — badge column with array accessor +column({ + ...infer("tags"), + type: "badge", + accessor: (row) => row.tags, + typeOptions: { + badgeVariantMap: { Premium: "warning", Office: "outline-info" }, + maxVisible: 2, + }, +}) + +// DescriptionCard — array badges with maxVisible + +``` + +Additional changes: + +- Unify badge variant resolution into shared `resolveBadgeVariant()` utility with `"outline-neutral"` as the default variant (previously `"neutral"` in DataTable) +- Export `BadgeVariant` and `BadgeOptions` types from the public API +- `inferColumns()` no longer sets a default `render` function — columns without an explicit `type` or `render` now display `—` for null/empty values (aligns with typed-column behavior) +- Deprecate `BadgeVariantType` in favor of `BadgeVariant` diff --git a/packages/core/src/components/badge-list.test.tsx b/packages/core/src/components/badge-list.test.tsx index d648f80e..2d1e7b97 100644 --- a/packages/core/src/components/badge-list.test.tsx +++ b/packages/core/src/components/badge-list.test.tsx @@ -23,8 +23,8 @@ describe("resolveBadgeVariant", () => { expect(resolveBadgeVariant("unknown", { defaultBadgeVariant: "warning" })).toBe("warning"); }); - it("returns 'neutral' when no options", () => { - expect(resolveBadgeVariant("anything", undefined)).toBe("neutral"); + it("returns 'outline-neutral' when no options", () => { + expect(resolveBadgeVariant("anything", undefined)).toBe("outline-neutral"); }); }); diff --git a/packages/core/src/components/badge-list.tsx b/packages/core/src/components/badge-list.tsx index 56c9db20..e9e0df53 100644 --- a/packages/core/src/components/badge-list.tsx +++ b/packages/core/src/components/badge-list.tsx @@ -29,7 +29,7 @@ export interface BadgeOptions { * map render the raw value (or sentence-cased value if enabled). */ badgeLabelMap?: Record; - /** Variant used when the value is not in `badgeVariantMap`. Default: `"neutral"`. */ + /** Variant used when the value is not in `badgeVariantMap`. Default: `"outline-neutral"`. */ defaultBadgeVariant?: BadgeVariant; } @@ -48,7 +48,7 @@ export function resolveBadgeVariant( const lower = map[value.toLowerCase()]; if (lower) return lower; } - return options?.defaultBadgeVariant ?? "neutral"; + return options?.defaultBadgeVariant ?? "outline-neutral"; } /** diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index 23e39ca5..f2830343 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -122,7 +122,7 @@ function renderDate(value: unknown, options: DateCellOptions | undefined): React } function renderBadge(value: unknown, options: BadgeCellOptions | undefined): ReactNode { - if (isEmpty(value)) return PLACEHOLDER; + if (isEmpty(value) || (Array.isArray(value) && value.length === 0)) return PLACEHOLDER; return ; } diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index ee0fcf8f..4b2cb9d1 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -741,6 +741,39 @@ describe("DataTable", () => { expect(container.textContent).not.toContain("d"); }); + it("renders placeholder for empty array in badge column", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: [] as string[], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags, + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("—"); + }); + it("renders link cells with anchor when href is provided", () => { function RouterWrapper({ children }: { children: ReactNode }) { return {wrapper({ children })}; diff --git a/packages/core/src/components/description-card/field-renderers.tsx b/packages/core/src/components/description-card/field-renderers.tsx index 6f1030d6..84a9bdee 100644 --- a/packages/core/src/components/description-card/field-renderers.tsx +++ b/packages/core/src/components/description-card/field-renderers.tsx @@ -6,7 +6,6 @@ import { BadgeList } from "../badge-list"; import { Tooltip } from "../tooltip"; import { Copy, Check, ExternalLink } from "lucide-react"; import type { ResolvedField, DateFormat } from "./types"; -import type { BadgeVariant } from "../badge-list"; import { resolveBadgeLabel } from "../badge-list"; import { useDescriptionCardT } from "./i18n"; @@ -350,7 +349,7 @@ function BadgeFieldRenderer({ field }: { field: ResolvedField }) { const badgeOptions = { badgeVariantMap: field.meta?.badgeVariantMap, badgeLabelMap: field.meta?.badgeLabelMap, - defaultBadgeVariant: field.meta?.defaultBadgeVariant ?? ("outline-neutral" as BadgeVariant), + defaultBadgeVariant: field.meta?.defaultBadgeVariant, }; const sentenceCaseBadges = field.meta?.sentenceCaseBadges ?? true; diff --git a/packages/core/src/components/description-card/types.ts b/packages/core/src/components/description-card/types.ts index b84a55b0..56eb34c5 100644 --- a/packages/core/src/components/description-card/types.ts +++ b/packages/core/src/components/description-card/types.ts @@ -42,7 +42,7 @@ export interface FieldMeta { * map render the raw value (or sentence-cased value if enabled). */ badgeLabelMap?: Record; - /** Variant used when the value is not in `badgeVariantMap`. Default: `"outline-neutral"`. */ + /** Variant used when the value is not in `badgeVariantMap`. Default: `"outline-neutral"` (from shared `resolveBadgeVariant`). */ defaultBadgeVariant?: BadgeVariant; /** Render badge labels in sentence case by default; set false to keep the original value */ sentenceCaseBadges?: boolean; From 412c16057361f584f00a40a9b05bc4866c26f7a4 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 22 May 2026 18:21:45 +0900 Subject: [PATCH 6/8] Remove redundunt accessor prop --- .changeset/badge-array-support.md | 1 - examples/nextjs-app/src/modules/pages/data-table-demo.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/.changeset/badge-array-support.md b/.changeset/badge-array-support.md index ca9c98ec..04cdf401 100644 --- a/.changeset/badge-array-support.md +++ b/.changeset/badge-array-support.md @@ -9,7 +9,6 @@ Add array badge support to DataTable and DescriptionCard with shared `BadgeList` column({ ...infer("tags"), type: "badge", - accessor: (row) => row.tags, typeOptions: { badgeVariantMap: { Premium: "warning", Office: "outline-info" }, maxVisible: 2, diff --git a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx index 3e83d371..6b7b5182 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -92,7 +92,6 @@ const productColumns = [ column({ ...infer("status"), type: "badge", - accessor: (row) => row.status, typeOptions: { badgeVariantMap: { Active: "success", @@ -104,7 +103,6 @@ const productColumns = [ column({ ...infer("tags"), type: "badge", - accessor: (row) => row.tags, typeOptions: { badgeVariantMap: { Premium: "warning", From 20f84cbaf0684f905069854c62af1fe641aafb3a Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 25 May 2026 11:08:16 +0900 Subject: [PATCH 7/8] fix(badge): handle all-null/empty arrays in renderBadge and fix hover test - renderBadge now filters array items and returns placeholder when all values are null or empty strings - Add tests for [null, null] and ["", ""] badge column cases - Change field-renderers test to use user.hover instead of user.click to match the openOnHover popover behavior --- .../components/data-table/cell-renderers.tsx | 4 +- .../components/data-table/data-table.test.tsx | 66 +++++++++++++++++++ .../description-card/field-renderers.test.tsx | 12 ++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index f2830343..8f829d9a 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -122,7 +122,9 @@ function renderDate(value: unknown, options: DateCellOptions | undefined): React } function renderBadge(value: unknown, options: BadgeCellOptions | undefined): ReactNode { - if (isEmpty(value) || (Array.isArray(value) && value.length === 0)) return PLACEHOLDER; + const items = Array.isArray(value) ? value : value != null ? [value] : []; + const nonEmpty = items.filter((v) => v != null && v !== ""); + if (nonEmpty.length === 0) return PLACEHOLDER; return ; } diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index 4b2cb9d1..277c38e9 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -774,6 +774,72 @@ describe("DataTable", () => { expect(container.textContent).toContain("—"); }); + it("renders placeholder for array of all-null values in badge column", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: [null, null] as (string | null)[], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags as unknown as string[], + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("—"); + }); + + it("renders placeholder for array of empty strings in badge column", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: ["", ""], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags, + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("—"); + }); + it("renders link cells with anchor when href is provided", () => { function RouterWrapper({ children }: { children: ReactNode }) { return {wrapper({ children })}; diff --git a/packages/core/src/components/description-card/field-renderers.test.tsx b/packages/core/src/components/description-card/field-renderers.test.tsx index 22eff877..cdfa60a7 100644 --- a/packages/core/src/components/description-card/field-renderers.test.tsx +++ b/packages/core/src/components/description-card/field-renderers.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { createAppShellWrapper } from "../../../tests/test-utils"; @@ -124,7 +124,7 @@ describe("renderField", () => { expect(container.textContent).not.toContain("+"); }); - it("shows overflow badges in popover on click", async () => { + it("shows overflow badges in popover on hover", async () => { const user = userEvent.setup(); render( renderField( @@ -138,10 +138,12 @@ describe("renderField", () => { ); const trigger = screen.getByText("+2"); - await user.click(trigger); + await user.hover(trigger); - expect(screen.getByText("C")).toBeDefined(); - expect(screen.getByText("D")).toBeDefined(); + await waitFor(() => { + expect(screen.getByText("C")).toBeDefined(); + expect(screen.getByText("D")).toBeDefined(); + }); }); }); From 92e29009d8d5e6d5d0c73d63bd33c3f3255326e3 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 25 May 2026 17:42:14 +0900 Subject: [PATCH 8/8] refactor(cell-renderers): convert renderText to switch(true) for readability --- .../components/data-table/cell-renderers.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index 8f829d9a..414b4526 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -48,11 +48,18 @@ function toDate(value: unknown): Date | null { } function renderText(value: unknown): ReactNode { - if (isEmpty(value)) return PLACEHOLDER; - if (typeof value === "boolean") return value ? "✓" : "✗"; - if (value instanceof Date) return value.toLocaleDateString(); - if (typeof value === "object") return JSON.stringify(value); - return String(value); + switch (true) { + case isEmpty(value): + return PLACEHOLDER; + case typeof value === "boolean": + return value ? "✓" : "✗"; + case value instanceof Date: + return value.toLocaleDateString(); + case typeof value === "object": + return JSON.stringify(value); + default: + return String(value); + } } function renderNumber(value: unknown, options: NumberCellOptions | undefined): ReactNode {