@elabs-ai/components-data 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,17 @@
1
1
  export {
2
2
  DataTable,
3
+ createSelectionColumn,
3
4
  type DataTableProps,
4
5
  type DataTableViewState,
5
6
  type DataTableServerArgs,
6
7
  type DataTableRowClickHandler,
8
+ // #69 round-1 fix (validator B3): this named type was declared and
9
+ // documented as exported ("Exported (not just declared) so a consumer's
10
+ // own `ColumnDef` literal type-checks against a NAMED type") but was never
11
+ // actually re-exported through this barrel — a consumer importing it from
12
+ // `@elabs-ai/components-data` got `TS2305`. `ColumnMeta`'s augmented keys
13
+ // already reached consumers via `packages/data/src/index.ts`'s TanStack
14
+ // re-export; this makes the NAMED type reachable too, matching both the
15
+ // source comment and the CHANGELOG entry.
16
+ type DataTableColumnMeta,
7
17
  } from "./data-table";
@@ -9,6 +9,7 @@ import {
9
9
  DropdownMenuLabel,
10
10
  DropdownMenuSeparator,
11
11
  DropdownMenuTrigger,
12
+ useLocale,
12
13
  } from "@elabs-ai/components-ui";
13
14
  import { cn } from "@elabs-ai/components-ui/lib/cn";
14
15
 
@@ -42,6 +43,7 @@ export const FacetFilter = forwardRef<HTMLButtonElement, FacetFilterProps>(funct
42
43
  { title, options, selected, onSelectedChange, className, ...props },
43
44
  ref,
44
45
  ) {
46
+ const { t } = useLocale();
45
47
  const selectedSet = new Set(selected);
46
48
  const toggle = (value: string) => {
47
49
  const next = new Set(selectedSet);
@@ -93,7 +95,9 @@ export const FacetFilter = forwardRef<HTMLButtonElement, FacetFilterProps>(funct
93
95
  {selected.length > 0 ? (
94
96
  <>
95
97
  <DropdownMenuSeparator />
96
- <DropdownMenuItem onSelect={() => onSelectedChange([])}>Clear filters</DropdownMenuItem>
98
+ <DropdownMenuItem onSelect={() => onSelectedChange([])}>
99
+ {t("data.facetFilter.clearFilters")}
100
+ </DropdownMenuItem>
97
101
  </>
98
102
  ) : null}
99
103
  </DropdownMenuContent>
@@ -0,0 +1,144 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, fn, userEvent, within } from "storybook/test";
4
+ import { FilterChip } from "./filter-chip";
5
+
6
+ const meta = {
7
+ title: "Data/FilterBar/FilterChip",
8
+ component: FilterChip,
9
+ parameters: {
10
+ layout: "padded",
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "The removable active-filter chip for `FilterBar`, with an optional secondary " +
15
+ 'count ("excluded 1,204"), passed to the base `trailing` slot (#284) rather than ' +
16
+ "folded into `label` — a long label truncates on its own, the count never loses " +
17
+ "characters to the ellipsis. Composes `@elabs-ai/components-ui`'s `FilterChip` — the " +
18
+ 'whole chip is a single button whose accessible name is "Remove filter: <label> · ' +
19
+ '<trailing>" (WCAG 2.5.3), so the count still reaches the accessible name, not only ' +
20
+ "the visible text.",
21
+ },
22
+ },
23
+ },
24
+ args: {
25
+ label: "Status: Failed",
26
+ onRemove: fn(),
27
+ },
28
+ tags: ["autodocs"],
29
+ } satisfies Meta<typeof FilterChip>;
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ export const Default: Story = {
34
+ play: async ({ canvasElement, args }) => {
35
+ const canvas = within(canvasElement);
36
+ const chip = canvas.getByRole("button", { name: "Remove filter: Status: Failed" });
37
+ await userEvent.click(chip);
38
+ await expect(args.onRemove).toHaveBeenCalledTimes(1);
39
+ },
40
+ };
41
+
42
+ /** A count with a caller-supplied label — "excluded 1,204", locale-formatted. */
43
+ export const WithCount: Story = {
44
+ args: { count: 1204, countLabel: "excluded" },
45
+ play: async ({ canvasElement }) => {
46
+ const canvas = within(canvasElement);
47
+ // Label and count are separate elements (#284) — the count is never
48
+ // folded into the truncatable label string.
49
+ await expect(canvas.getByText("Status: Failed")).toBeInTheDocument();
50
+ await expect(canvas.getByText("excluded 1,204")).toBeInTheDocument();
51
+ await expect(
52
+ canvas.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
53
+ ).toBeInTheDocument();
54
+ },
55
+ };
56
+
57
+ /** A bare count with no `countLabel` — just the formatted number. */
58
+ export const BareCount: Story = {
59
+ args: { count: 1204 },
60
+ play: async ({ canvasElement }) => {
61
+ const canvas = within(canvasElement);
62
+ await expect(canvas.getByText("Status: Failed")).toBeInTheDocument();
63
+ await expect(canvas.getByText("1,204")).toBeInTheDocument();
64
+ },
65
+ };
66
+
67
+ /** Several chips in a run — removing one never touches its siblings. */
68
+ function MultipleChipsDemo() {
69
+ const [chips, setChips] = useState([
70
+ { id: "status", label: "Status: Failed", count: 1204, countLabel: "excluded" },
71
+ { id: "region", label: "Region: EU" },
72
+ ]);
73
+ return (
74
+ <div className="flex flex-wrap items-center gap-1.5">
75
+ {chips.map((chip) => (
76
+ <FilterChip
77
+ key={chip.id}
78
+ label={chip.label}
79
+ count={chip.count}
80
+ countLabel={chip.countLabel}
81
+ onRemove={() => setChips((prev) => prev.filter((c) => c.id !== chip.id))}
82
+ />
83
+ ))}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ export const MultipleChips: Story = {
89
+ render: () => <MultipleChipsDemo />,
90
+ play: async ({ canvasElement }) => {
91
+ const canvas = within(canvasElement);
92
+ const statusChip = canvas.getByRole("button", {
93
+ name: "Remove filter: Status: Failed · excluded 1,204",
94
+ });
95
+ const regionChip = canvas.getByRole("button", { name: "Remove filter: Region: EU" });
96
+ await userEvent.click(statusChip);
97
+ await expect(statusChip).not.toBeInTheDocument();
98
+ await expect(regionChip).toBeInTheDocument();
99
+ },
100
+ };
101
+
102
+ /**
103
+ * A label long enough to overflow a 280px container (#284). The label
104
+ * truncates with an ellipsis; the count stays fully visible — CSS
105
+ * `text-overflow: ellipsis` can only reach the label's own span, never the
106
+ * count's sibling element.
107
+ */
108
+ export const LongLabelWithCount: Story = {
109
+ args: {
110
+ label: "Status: Awaiting downstream reconciliation review",
111
+ count: 1204,
112
+ countLabel: "excluded",
113
+ },
114
+ decorators: [
115
+ (Story) => (
116
+ <div className="w-[280px]">
117
+ <Story />
118
+ </div>
119
+ ),
120
+ ],
121
+ play: async ({ canvasElement }) => {
122
+ const canvas = within(canvasElement);
123
+ const count = await canvas.findByText("excluded 1,204");
124
+ await expect(count).toBeInTheDocument();
125
+
126
+ const truncating = canvasElement.querySelector('[class*="truncate"]');
127
+ await expect(truncating).not.toBeNull();
128
+ // The count must never be a descendant of the truncating label span.
129
+ await expect(truncating?.contains(count)).toBe(false);
130
+
131
+ await expect(
132
+ canvas.getByRole("button", {
133
+ name: "Remove filter: Status: Awaiting downstream reconciliation review · excluded 1,204",
134
+ }),
135
+ ).toBeInTheDocument();
136
+ },
137
+ };
138
+
139
+ export const WithCountHighDecoration: Story = {
140
+ name: "With count — high decoration",
141
+ globals: { decoration: "10" },
142
+ args: { count: 1204, countLabel: "excluded" },
143
+ play: WithCount.play,
144
+ };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * filter-chip.test.tsx — smoke + count-in-accessible-name lock (#221).
3
+ *
4
+ * `FilterChip` composes `@elabs-ai/components-ui`'s FilterChip; the contract worth
5
+ * locking here is specific to the count feature this wrapper adds: the
6
+ * formatted count reaches the VISIBLE text and the chip's ACCESSIBLE NAME
7
+ * (WCAG 2.5.3), and removing one chip never touches its siblings.
8
+ */
9
+ import { describe, expect, it, vi } from "vitest";
10
+ import { render, screen } from "@testing-library/react";
11
+ import userEvent from "@testing-library/user-event";
12
+ import { FilterChip } from "./filter-chip";
13
+
14
+ describe("FilterChip", () => {
15
+ it("renders the bare label with no count", () => {
16
+ render(<FilterChip label="Status: Failed" onRemove={vi.fn()} />);
17
+ expect(
18
+ screen.getByRole("button", { name: "Remove filter: Status: Failed" }),
19
+ ).toBeInTheDocument();
20
+ expect(screen.getByText("Status: Failed")).toBeInTheDocument();
21
+ });
22
+
23
+ it("renders 'excluded 1,204' (locale-formatted) alongside the label", () => {
24
+ render(
25
+ <FilterChip label="Status: Failed" count={1204} countLabel="excluded" onRemove={vi.fn()} />,
26
+ );
27
+ expect(screen.getByText("Status: Failed")).toBeInTheDocument();
28
+ expect(screen.getByText("excluded 1,204")).toBeInTheDocument();
29
+ });
30
+
31
+ it("folds the count into the chip's ACCESSIBLE NAME, not only its visible text", () => {
32
+ render(
33
+ <FilterChip label="Status: Failed" count={1204} countLabel="excluded" onRemove={vi.fn()} />,
34
+ );
35
+ expect(
36
+ screen.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
37
+ ).toBeInTheDocument();
38
+ });
39
+
40
+ it("renders a bare formatted count with no countLabel", () => {
41
+ render(<FilterChip label="Status: Failed" count={1204} onRemove={vi.fn()} />);
42
+ expect(screen.getByText("Status: Failed")).toBeInTheDocument();
43
+ expect(screen.getByText("1,204")).toBeInTheDocument();
44
+ });
45
+
46
+ it("fires onRemove for the clicked chip only, leaving sibling chips untouched", async () => {
47
+ const user = userEvent.setup();
48
+ const removeFirst = vi.fn();
49
+ const removeSecond = vi.fn();
50
+ render(
51
+ <>
52
+ <FilterChip label="Status: Failed" onRemove={removeFirst} />
53
+ <FilterChip label="Region: EU" onRemove={removeSecond} />
54
+ </>,
55
+ );
56
+ await user.click(screen.getByRole("button", { name: "Remove filter: Status: Failed" }));
57
+ expect(removeFirst).toHaveBeenCalledTimes(1);
58
+ expect(removeSecond).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it("merges a caller className onto the root", () => {
62
+ render(<FilterChip label="Status: Failed" onRemove={vi.fn()} className="extra" />);
63
+ expect(screen.getByRole("button")).toHaveClass("extra");
64
+ });
65
+
66
+ it("keeps the count in a non-shrinking element so truncation can only reach the label", () => {
67
+ const { container } = render(
68
+ <FilterChip
69
+ label="Status: Awaiting downstream reconciliation review"
70
+ count={1204}
71
+ countLabel="excluded"
72
+ onRemove={vi.fn()}
73
+ />,
74
+ );
75
+ const truncating = container.querySelector('[class*="truncate"]');
76
+ expect(truncating).not.toBeNull();
77
+ expect(truncating).toHaveTextContent("Status: Awaiting downstream reconciliation review");
78
+ expect(truncating?.textContent).toBe("Status: Awaiting downstream reconciliation review");
79
+
80
+ // The count lives in its own, sibling element — never inside the truncating one.
81
+ const count = screen.getByText("excluded 1,204");
82
+ expect(count).not.toBe(truncating);
83
+ expect(truncating?.contains(count)).toBe(false);
84
+ });
85
+
86
+ // Runtime lock (PR #408 review round 2): `Omit<BaseFilterChipProps, "trailing">`
87
+ // only stops a `trailing` prop written as an object LITERAL — TypeScript's
88
+ // excess-property check does not apply to a spread of an already-declared
89
+ // variable, so a typed caller can still get `trailing` into `props` this
90
+ // way and have it win at render. This must hold at runtime regardless of
91
+ // what the type system caught.
92
+ it("keeps the derived count even when a caller spreads a `trailing` override through a variable", () => {
93
+ const hijack = { trailing: "hijacked" } as { trailing: string };
94
+ render(
95
+ <FilterChip
96
+ label="Status: Failed"
97
+ count={1204}
98
+ countLabel="excluded"
99
+ onRemove={vi.fn()}
100
+ {...hijack}
101
+ />,
102
+ );
103
+ expect(screen.getByText("excluded 1,204")).toBeInTheDocument();
104
+ expect(screen.queryByText("hijacked")).not.toBeInTheDocument();
105
+ expect(
106
+ screen.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
107
+ ).toBeInTheDocument();
108
+ });
109
+
110
+ // Type-level lock, same shape as ContextRail's `children` omission
111
+ // (context-rail.test.tsx #15): `FilterChipProps` is re-derived from the
112
+ // base package's `BaseFilterChipProps` and must omit `trailing` alongside
113
+ // `label`. The wrapper computes its OWN `trailing` from `count`/
114
+ // `countLabel` and strips any caller-supplied `trailing` from `props` at
115
+ // RUNTIME before forwarding to the base component, so a caller-supplied
116
+ // `trailing` — whether it type-checks as an object literal (it doesn't,
117
+ // thanks to this Omit) or slips through a spread of an already-declared
118
+ // variable (it does; see the runtime lock above) — can never win at
119
+ // render. Fails to typecheck the moment `trailing` is dropped from the
120
+ // `Omit`.
121
+ it("(type-level) does not accept a `trailing` prop", () => {
122
+ function typeOnly() {
123
+ return (
124
+ <FilterChip
125
+ label="Status: Failed"
126
+ count={1204}
127
+ // @ts-expect-error — `trailing` is omitted from `FilterChipProps`;
128
+ // the wrapper derives its own `trailing` from `count`/`countLabel`
129
+ // and must not let a caller override it.
130
+ trailing="hijacked"
131
+ onRemove={vi.fn()}
132
+ />
133
+ );
134
+ }
135
+ expect(typeof typeOnly).toBe("function");
136
+ });
137
+ });
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ /**
4
+ * filter-chip.tsx — `@elabs-ai/components-data`'s removable filter chip, with an optional
5
+ * secondary count ("excluded 1,204") for `ProcessFilterBar` (RM-056, #221).
6
+ *
7
+ * Deliberately a thin COMPOSING wrapper around `@elabs-ai/components-ui`'s `FilterChip`
8
+ * (`view-toolbar.tsx`, #331) rather than a second implementation — the dedupe
9
+ * audit found the real, accessible, whole-chip-as-button `FilterChip` already
10
+ * lives there (WCAG 2.5.8 target size, WCAG 2.5.3 "Remove filter: <label>"
11
+ * accessible name). Building a second one in `packages/data` would duplicate
12
+ * that work; this wrapper reuses it and passes `count`/`countLabel` through
13
+ * the base component's `trailing` slot (#284) — a second, non-shrinking text
14
+ * element, distinct from the truncatable `label` — so the count reaches the
15
+ * chip's ACCESSIBLE NAME (screen readers hear "Remove filter: Status: Failed
16
+ * · excluded 1,204") AND survives truncation in the visible chip, instead of
17
+ * being folded into the one string CSS `truncate` can clip from the tail.
18
+ */
19
+ import { forwardRef } from "react";
20
+ import {
21
+ FilterChip as BaseFilterChip,
22
+ type FilterChipProps as BaseFilterChipProps,
23
+ useLocale,
24
+ } from "@elabs-ai/components-ui";
25
+
26
+ // `trailing` is omitted alongside `label`: this wrapper derives its OWN
27
+ // `trailing` from `count`/`countLabel`. The `Omit` blocks `trailing` written
28
+ // as an object LITERAL, but TypeScript's excess-property check does not
29
+ // apply to a spread of an already-declared variable — `const extra = {
30
+ // trailing: "x" }; <FilterChip {...extra} />` still type-checks, and the
31
+ // value would land in `props` regardless of JSX attribute order (PR #408
32
+ // review round 2). So the `Omit` is necessary but not sufficient: below,
33
+ // `trailing` is also stripped from `props` at RUNTIME before it reaches the
34
+ // base component, so a caller-supplied `trailing` — literal or
35
+ // spread-smuggled — can never win at render, the same advertised-but-inert
36
+ // failure mode #382/#284-round-1 already closed elsewhere in the repo
37
+ // (`ContextRail`'s `children` omission).
38
+ export interface FilterChipProps extends Omit<BaseFilterChipProps, "label" | "trailing"> {
39
+ /**
40
+ * Label-in-value text — `"Status: Failed"`, never `"Status = failed"` and
41
+ * never a bare `"Failed"`. Same contract as the base `FilterChip`.
42
+ */
43
+ label: string;
44
+ /**
45
+ * How many records this active filter excluded (or matched) — rendered as a
46
+ * secondary, locale-formatted segment alongside `label`. Omit for a bare
47
+ * chip with no count.
48
+ */
49
+ count?: number;
50
+ /**
51
+ * The word placed before the formatted count, e.g. `"excluded"` →
52
+ * `"excluded 1,204"`. Omitted by default: a bare `count` renders as just the
53
+ * formatted number.
54
+ */
55
+ countLabel?: string;
56
+ }
57
+
58
+ /**
59
+ * A removable active-filter chip with an optional secondary count.
60
+ *
61
+ * `onRemove` stays REQUIRED (inherited from the base `FilterChip`, diverging
62
+ * from this item's spec draft) — the whole chip IS the remove control, so a
63
+ * chip with no removal affordance is a plain `Badge`, not this component.
64
+ */
65
+ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(function FilterChip(
66
+ { label, count, countLabel, ...props },
67
+ ref,
68
+ ) {
69
+ const { formatNumber } = useLocale();
70
+ const countText =
71
+ count === undefined
72
+ ? undefined
73
+ : countLabel
74
+ ? `${countLabel} ${formatNumber(count)}`
75
+ : formatNumber(count);
76
+
77
+ // Runtime guard (belt and braces alongside the `Omit` above): a caller can
78
+ // still smuggle `trailing` into `props` through a spread of an
79
+ // already-declared variable, which the type system cannot catch. Strip it
80
+ // here so the derived count wins regardless of prop order.
81
+ const { trailing: _ignoredTrailing, ...restProps } = props as Omit<BaseFilterChipProps, "label">;
82
+
83
+ return (
84
+ <BaseFilterChip
85
+ ref={ref}
86
+ data-slot="filter-chip"
87
+ label={label}
88
+ {...restProps}
89
+ trailing={countText}
90
+ />
91
+ );
92
+ });
@@ -1 +1,2 @@
1
1
  export { FilterBar, type FilterBarProps } from "./filter-bar";
2
+ export { FilterChip, type FilterChipProps } from "./filter-chip";
package/src/index.ts CHANGED
@@ -12,7 +12,16 @@ export * from "./facet-filter";
12
12
  export * from "./column-picker";
13
13
 
14
14
  // Re-export the most common TanStack types so consumers don't need a direct dep.
15
- export type { ColumnDef, ColumnPinningState, Table, Row, CellContext } from "@tanstack/react-table";
15
+ export type {
16
+ ColumnDef,
17
+ ColumnMeta,
18
+ ColumnPinningState,
19
+ ColumnSizingState,
20
+ RowSelectionState,
21
+ Table,
22
+ Row,
23
+ CellContext,
24
+ } from "@tanstack/react-table";
16
25
 
17
26
  // CSV helpers — pure, dependency-free serializer + browser download trigger.
18
27
  export * from "./to-csv";
@@ -10,6 +10,9 @@ const meta = {
10
10
  docs: {
11
11
  description: {
12
12
  component:
13
+ "The SEARCH field, with a leading icon and a clear button; a plain text field is " +
14
+ "`Core/Input` — see " +
15
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
13
16
  "Controlled search field with a leading icon and a clear button. Pair it with " +
14
17
  "`FilterBar` and drive a DataTable's global filter from the `toolbar` render-prop. " +
15
18
  "The label is visually hidden but real — the placeholder is never the accessible name.",
@@ -59,7 +59,7 @@ export function SearchInput({
59
59
  type="button"
60
60
  onClick={() => onValueChange("")}
61
61
  aria-label="Clear search"
62
- className="absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-fast ease-standard hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring animate-in fade-in zoom-in-95 duration-fast ease-entrance"
62
+ className="absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-fast ease-standard hover:text-foreground focus-ring animate-in fade-in zoom-in-95 duration-fast ease-entrance"
63
63
  >
64
64
  <svg
65
65
  width="14"
@@ -3,7 +3,7 @@
3
3
  * (app-shell + DataTable with toolbar). This story is the single source of
4
4
  * truth: `pnpm gen:templates` derives the consumer template source
5
5
  * (`docs/playbooks/templates/data-app.tsx`) from it.
6
- * Verify across all three themes with globals=theme:<slug>.
6
+ * Verify across every theme with globals=theme:<slug>.
7
7
  */
8
8
  import type { Meta, StoryObj } from "@storybook/react-vite";
9
9
  import { useState } from "react";
@@ -52,9 +52,11 @@ const columns: ColumnDef<DataRow>[] = [
52
52
  {
53
53
  accessorKey: "records",
54
54
  header: "Records",
55
- cell: ({ row }) => (
56
- <span className="tabular-nums">{row.original.records.toLocaleString()}</span>
57
- ),
55
+ // #69: `meta.numeric` is the seam — DataTable applies `tabular-nums` +
56
+ // end-alignment on both `<th>` and `<td>`, so the exemplar no longer
57
+ // hand-rolls a wrapper span for it.
58
+ meta: { numeric: true },
59
+ cell: ({ row }) => row.original.records.toLocaleString(),
58
60
  },
59
61
  { accessorKey: "lastRun", header: "Last run" },
60
62
  ];