@iloveagents/foundry-web-ui 0.17.0 → 0.19.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.
@@ -11,6 +11,8 @@ export interface DataTableFacetedFilterProps<T> {
11
11
  options?: DataTableFacetOption[];
12
12
  /** Show a search box inside the list once there are more options than this (default 7). */
13
13
  searchableFrom?: number;
14
+ /** Called when the popover closes with nothing selected (a revealed facet goes back into the menu). */
15
+ onEmptyClose?: () => void;
14
16
  className?: string;
15
17
  }
16
- export declare function DataTableFacetedFilter<T>({ column, title, options: declared, searchableFrom, className, }: DataTableFacetedFilterProps<T>): import("react/jsx-runtime").JSX.Element;
18
+ export declare function DataTableFacetedFilter<T>({ column, title, options: declared, searchableFrom, onEmptyClose, className, }: DataTableFacetedFilterProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
4
4
  import { Button, cn } from "@iloveagents/foundry-web-primitives";
5
5
  import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover.js";
6
6
  import { columnLabel, facetOptions, normalizeFacetValue, } from "./facets.js";
7
- export function DataTableFacetedFilter({ column, title, options: declared, searchableFrom = 7, className, }) {
7
+ export function DataTableFacetedFilter({ column, title, options: declared, searchableFrom = 7, onEmptyClose, className, }) {
8
8
  const [query, setQuery] = useState("");
9
9
  const label = title ?? columnLabel(column);
10
10
  const selected = normalizeFacetValue(column.getFilterValue());
@@ -20,9 +20,15 @@ export function DataTableFacetedFilter({ column, title, options: declared, searc
20
20
  : [...selected, value];
21
21
  column.setFilterValue(next.length ? next : undefined);
22
22
  };
23
- return (_jsxs(Popover, { onOpenChange: (open) => !open && setQuery(""), children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", "data-facet": column.id, "data-active": selected.length ? "" : undefined, className: cn("h-8 rounded-xl border-dashed border-border/60 bg-background/65 shadow-none hover:bg-muted/26", selected.length && "border-solid border-primary/30", className), children: [_jsx(PlusCircle, { className: "size-4" }), label, selected.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "mx-0.5 h-4 w-px bg-border/60", "aria-hidden": "true" }), _jsx("span", { className: "rounded-md bg-muted px-1.5 py-0.5 text-xs font-normal lg:hidden", children: selected.length }), _jsx("span", { className: "hidden gap-1 lg:flex", children: selected.length > 2 ? (_jsxs("span", { className: "rounded-md bg-muted px-1.5 py-0.5 text-xs font-normal", children: [selected.length, " selected"] })) : (options
24
- .filter((option) => selectedSet.has(option.value))
25
- .map((option) => (_jsx("span", { className: "rounded-md bg-muted px-1.5 py-0.5 text-xs font-normal", children: option.label }, option.value)))) })] })) : null] }) }), _jsxs(PopoverContent, { align: "start", className: "w-56 p-1.5", children: [options.length >= searchableFrom ? (_jsx("input", { value: query, onChange: (event) => setQuery(event.target.value), placeholder: label, "aria-label": `Search ${label} options`, className: "mb-1 h-8 w-full rounded-lg border border-border/45 bg-background/72 px-2 text-sm outline-none focus:border-primary/30" })) : null, _jsx("div", { role: "group", "aria-label": `${label} filter`, className: "max-h-72 space-y-0.5 overflow-y-auto", children: shown.length === 0 ? (_jsx("div", { className: "px-2 py-4 text-center text-xs text-muted-foreground", children: "No options." })) : (shown.map((option) => {
23
+ return (_jsxs(Popover, { onOpenChange: (open) => {
24
+ if (open)
25
+ return;
26
+ setQuery("");
27
+ if (normalizeFacetValue(column.getFilterValue()).length === 0)
28
+ onEmptyClose?.();
29
+ }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", "data-facet": column.id, "data-active": selected.length ? "" : undefined, className: cn("h-8 rounded-xl border-dashed border-border/60 bg-background/65 shadow-none hover:bg-muted/26", selected.length && "border-solid border-primary/30", className), children: [_jsx(PlusCircle, { className: "size-4" }), label, selected.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "mx-0.5 h-4 w-px bg-border/60", "aria-hidden": "true" }), selected.length > 2 ? (_jsxs("span", { className: "rounded-md bg-muted px-1.5 py-0.5 text-xs font-normal tabular-nums", children: [_jsx("span", { "aria-hidden": "true", children: selected.length }), _jsxs("span", { className: "sr-only", children: [selected.length, " selected"] })] })) : (options
30
+ .filter((option) => selectedSet.has(option.value))
31
+ .map((option) => (_jsx("span", { title: option.label, className: "max-w-[8rem] truncate rounded-md bg-muted px-1.5 py-0.5 text-xs font-normal", children: option.label }, option.value))))] })) : null] }) }), _jsxs(PopoverContent, { align: "start", className: "w-56 p-1.5", children: [options.length >= searchableFrom ? (_jsx("input", { value: query, onChange: (event) => setQuery(event.target.value), placeholder: label, "aria-label": `Search ${label} options`, className: "mb-1 h-8 w-full rounded-lg border border-border/45 bg-background/72 px-2 text-sm outline-none focus:border-primary/30" })) : null, _jsx("div", { role: "group", "aria-label": `${label} filter`, className: "max-h-72 space-y-0.5 overflow-y-auto", children: shown.length === 0 ? (_jsx("div", { className: "px-2 py-4 text-center text-xs text-muted-foreground", children: "No options." })) : (shown.map((option) => {
26
32
  const checked = selectedSet.has(option.value);
27
33
  const Icon = option.icon;
28
34
  return (_jsxs("button", { type: "button", role: "checkbox", "aria-checked": checked, onClick: () => toggle(option.value), className: cn("flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent", option.count === 0 && !checked && "text-muted-foreground"), children: [_jsx("span", { className: cn("flex size-4 shrink-0 items-center justify-center rounded border border-border", checked
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The frame around a list: normally a plain container, in focus mode a
3
+ * fullscreen layer (Escape leaves it). `DataTableToolbar` shows the focus
4
+ * button whenever it renders inside a frame — users with wide lists get a
5
+ * way out of a cramped page without the host doing anything.
6
+ */
7
+ import { type HTMLAttributes, type ReactNode } from "react";
8
+ export interface DataTableFrameContextValue {
9
+ focused: boolean;
10
+ setFocused: (focused: boolean) => void;
11
+ }
12
+ /** The enclosing frame's focus state, or `null` outside a frame. */
13
+ export declare function useDataTableFrame(): DataTableFrameContextValue | null;
14
+ export interface DataTableFrameProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
15
+ children: ReactNode;
16
+ /** Classes applied in focus mode on top of the fullscreen layer. */
17
+ focusClassName?: string;
18
+ defaultFocused?: boolean;
19
+ }
20
+ export declare function DataTableFrame({ children, className, focusClassName, defaultFocused, ...rest }: DataTableFrameProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The frame around a list: normally a plain container, in focus mode a
4
+ * fullscreen layer (Escape leaves it). `DataTableToolbar` shows the focus
5
+ * button whenever it renders inside a frame — users with wide lists get a
6
+ * way out of a cramped page without the host doing anything.
7
+ */
8
+ import { createContext, useContext, useEffect, useMemo, useState, } from "react";
9
+ import { cn } from "@iloveagents/foundry-web-primitives";
10
+ const DataTableFrameContext = createContext(null);
11
+ /** The enclosing frame's focus state, or `null` outside a frame. */
12
+ export function useDataTableFrame() {
13
+ return useContext(DataTableFrameContext);
14
+ }
15
+ export function DataTableFrame({ children, className, focusClassName, defaultFocused = false, ...rest }) {
16
+ const [focused, setFocused] = useState(defaultFocused);
17
+ useEffect(() => {
18
+ if (!focused)
19
+ return;
20
+ const onKeyDown = (event) => {
21
+ if (event.key === "Escape")
22
+ setFocused(false);
23
+ };
24
+ document.addEventListener("keydown", onKeyDown);
25
+ const previousOverflow = document.body.style.overflow;
26
+ document.body.style.overflow = "hidden";
27
+ return () => {
28
+ document.removeEventListener("keydown", onKeyDown);
29
+ document.body.style.overflow = previousOverflow;
30
+ };
31
+ }, [focused]);
32
+ const value = useMemo(() => ({ focused, setFocused }), [focused]);
33
+ return (_jsx(DataTableFrameContext.Provider, { value: value, children: _jsx("div", { ...rest, "data-focused": focused ? "" : undefined, className: focused
34
+ ? cn("fixed inset-0 z-50 flex flex-col gap-3 overflow-auto bg-background p-4 sm:p-6", focusClassName)
35
+ : className, children: children }) }));
36
+ }
@@ -1,27 +1,60 @@
1
1
  /**
2
- * Search + facets + Reset on the left, actions + View menu on the right.
3
- * Facets are declared by column id; everything else is read off the table.
2
+ * Two bands, so a list with many filters still reads as ordered rather than
3
+ * as a ragged wrap:
4
+ *
5
+ * row 1 search …………………………………… Filters (n) · actions · View · ⤢
6
+ * row 2 facet · facet · facet · + Filter · Reset
7
+ *
8
+ * Row one never wraps (its right group is fixed); filters live on their own
9
+ * row, and only the *pinned* facets (explicit `pinned`, else the first
10
+ * `pinnedFacets`) sit there — the rest wait behind "+ Filter" and join once
11
+ * chosen or active. On a dense, Excel-like screen the filter row collapses
12
+ * behind the "Filters" toggle, which keeps the active count visible.
13
+ *
14
+ * `layout="inline"` puts everything back on one wrapping row (a small list
15
+ * with one or two facets and no actions).
4
16
  */
5
17
  import type { Table } from "@tanstack/react-table";
6
- import type { ReactNode } from "react";
7
- import type { DataTableFacetOption } from "./facets.js";
18
+ import { type ReactNode } from "react";
19
+ import { type DataTableFacetOption } from "./facets.js";
8
20
  export interface DataTableFacet {
9
21
  columnId: string;
10
22
  title?: string;
11
23
  options?: DataTableFacetOption[];
24
+ /** Always show this facet; unset → the first `pinnedFacets` are pinned. */
25
+ pinned?: boolean;
12
26
  }
27
+ export type DataTableToolbarLayout = "auto" | "inline" | "stacked";
13
28
  export interface DataTableToolbarProps<T> {
14
29
  table: Table<T>;
15
30
  facets?: DataTableFacet[];
31
+ /**
32
+ * "auto" (default): filters get their own row as soon as there are facets;
33
+ * "stacked": always; "inline": one wrapping row.
34
+ */
35
+ layout?: DataTableToolbarLayout;
36
+ /** Show the Filters toggle that hides the filter row (default true when stacked). */
37
+ collapsibleFilters?: boolean;
38
+ /** Whether the filter row starts open (default `true`; when closed the toggle still shows the active count). */
39
+ defaultFiltersOpen?: boolean;
40
+ /** Facets always visible when none declares `pinned` (default 3; `Infinity` shows all). */
41
+ pinnedFacets?: number;
16
42
  searchPlaceholder?: string;
17
43
  /** Hide the search box. */
18
44
  search?: boolean;
19
45
  /** Hide the View (column visibility) menu. */
20
46
  viewOptions?: boolean;
47
+ /** Hide the focus (fullscreen) button that appears inside a `DataTableFrame`. */
48
+ focus?: boolean;
21
49
  /** Extra controls rendered next to the facets (left side). */
22
50
  children?: ReactNode;
23
51
  /** Controls rendered on the right, before the View menu. */
24
52
  actions?: ReactNode;
25
53
  className?: string;
26
54
  }
27
- export declare function DataTableToolbar<T>({ table, facets, searchPlaceholder, search, viewOptions, children, actions, className, }: DataTableToolbarProps<T>): import("react/jsx-runtime").JSX.Element;
55
+ /** Which facets render as buttons: pinned ones, revealed ones, and any with a value. */
56
+ export declare function visibleFacets<T>(table: Table<T>, facets: DataTableFacet[], revealed: ReadonlySet<string>, pinnedFacets: number): {
57
+ shown: DataTableFacet[];
58
+ hidden: DataTableFacet[];
59
+ };
60
+ export declare function DataTableToolbar<T>({ table, facets, layout, collapsibleFilters, defaultFiltersOpen, pinnedFacets, searchPlaceholder, search, viewOptions, focus, children, actions, className, }: DataTableToolbarProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -1,18 +1,68 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Search, X } from "lucide-react";
2
+ import { Maximize2, Minimize2, Plus, Search, SlidersHorizontal, X } from "lucide-react";
3
+ import { useState } from "react";
3
4
  import { Button, Input, cn } from "@iloveagents/foundry-web-primitives";
5
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../ui/dropdown-menu.js";
4
6
  import { DataTableFacetedFilter } from "./data-table-faceted-filter.js";
7
+ import { useDataTableFrame } from "./data-table-frame.js";
5
8
  import { DataTableViewOptions } from "./data-table-view-options.js";
6
- export function DataTableToolbar({ table, facets = [], searchPlaceholder = "Search…", search = true, viewOptions = true, children, actions, className, }) {
9
+ import { columnLabel, normalizeFacetValue } from "./facets.js";
10
+ /** Which facets render as buttons: pinned ones, revealed ones, and any with a value. */
11
+ export function visibleFacets(table, facets, revealed, pinnedFacets) {
12
+ const explicit = facets.some((facet) => facet.pinned !== undefined);
13
+ const shown = [];
14
+ const hidden = [];
15
+ facets.forEach((facet, index) => {
16
+ const column = table.getColumn(facet.columnId);
17
+ if (!column)
18
+ return;
19
+ const pinned = explicit ? facet.pinned === true : index < pinnedFacets;
20
+ const active = normalizeFacetValue(column.getFilterValue()).length > 0;
21
+ (pinned || active || revealed.has(facet.columnId) ? shown : hidden).push(facet);
22
+ });
23
+ return { shown, hidden };
24
+ }
25
+ export function DataTableToolbar({ table, facets = [], layout = "auto", collapsibleFilters, defaultFiltersOpen = true, pinnedFacets = 3, searchPlaceholder = "Search…", search = true, viewOptions = true, focus = true, children, actions, className, }) {
26
+ const [revealed, setRevealed] = useState(() => new Set());
27
+ const [filtersOpen, setFiltersOpen] = useState(defaultFiltersOpen);
28
+ const frame = useDataTableFrame();
29
+ const focusButton = focus && frame ? (_jsx(Button, { type: "button", variant: "outline", size: "sm", "aria-pressed": frame.focused, "aria-label": frame.focused ? "Leave focus mode" : "Focus on the list", title: frame.focused ? "Leave focus mode (Esc)" : "Focus on the list", onClick: () => frame.setFocused(!frame.focused), className: "size-8 rounded-xl border-border/45 bg-background/65 p-0 shadow-none hover:bg-muted/26", children: frame.focused ? _jsx(Minimize2, { className: "size-4" }) : _jsx(Maximize2, { className: "size-4" }) })) : null;
7
30
  const state = table.getState();
8
- const isFiltered = state.columnFilters.length > 0 || Boolean(state.globalFilter);
9
- return (_jsxs("div", { className: cn("flex flex-wrap items-center gap-2", className), children: [search ? (_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { type: "search", value: state.globalFilter ?? "", onChange: (event) => table.setGlobalFilter(event.target.value), placeholder: searchPlaceholder, "aria-label": searchPlaceholder, className: "h-8 w-[160px] rounded-xl border-border/45 bg-background/72 pl-8 text-sm shadow-none lg:w-[250px]" })] })) : null, facets.map((facet) => {
10
- const column = table.getColumn(facet.columnId);
11
- if (!column)
12
- return null;
13
- return (_jsx(DataTableFacetedFilter, { column: column, title: facet.title, options: facet.options }, facet.columnId));
14
- }), children, isFiltered ? (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
15
- table.resetColumnFilters();
16
- table.setGlobalFilter("");
17
- }, className: "h-8 rounded-xl px-2.5", children: ["Reset", _jsx(X, { className: "size-4" })] })) : null, actions || viewOptions ? (_jsxs("div", { className: "ml-auto flex items-center gap-2", children: [actions, viewOptions ? _jsx(DataTableViewOptions, { table: table }) : null] })) : null] }));
31
+ const isFiltered = state.columnFilters.length > 0 || Boolean(state.globalFilter) || revealed.size > 0;
32
+ const { shown, hidden } = visibleFacets(table, facets, revealed, pinnedFacets);
33
+ // `children` alone (extra controls, no facets) is not a reason to open a
34
+ // second band — it rides in the filter row only when there is one.
35
+ const stacked = layout === "stacked" || (layout === "auto" && facets.length > 0);
36
+ const canCollapse = stacked && (collapsibleFilters ?? true);
37
+ const activeFilters = state.columnFilters.length;
38
+ const showFilters = !canCollapse || filtersOpen;
39
+ const facetTitle = (facet) => {
40
+ const column = table.getColumn(facet.columnId);
41
+ return facet.title ?? (column ? columnLabel(column) : facet.columnId);
42
+ };
43
+ const searchNode = search ? (_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { type: "search", value: state.globalFilter ?? "", onChange: (event) => table.setGlobalFilter(event.target.value), placeholder: searchPlaceholder, "aria-label": searchPlaceholder, className: "h-8 w-[160px] rounded-xl border-border/45 bg-background/72 pl-8 text-sm shadow-none lg:w-[250px]" })] })) : null;
44
+ const facetNodes = shown.map((facet) => {
45
+ const column = table.getColumn(facet.columnId);
46
+ if (!column)
47
+ return null;
48
+ return (_jsx(DataTableFacetedFilter, { column: column, title: facet.title, options: facet.options, onEmptyClose: revealed.has(facet.columnId)
49
+ ? () => setRevealed((current) => {
50
+ const next = new Set(current);
51
+ next.delete(facet.columnId);
52
+ return next;
53
+ })
54
+ : undefined }, facet.columnId));
55
+ });
56
+ const addFilterNode = hidden.length > 0 ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", "data-facet-menu": "", className: "h-8 rounded-xl px-2.5 text-muted-foreground hover:text-foreground", children: [_jsx(Plus, { className: "size-4" }), "Filter"] }) }), _jsxs(DropdownMenuContent, { align: "start", className: "w-48 rounded-xl border-border/45 bg-popover/95 shadow-lg", children: [_jsx(DropdownMenuLabel, { children: "Filter by" }), _jsx(DropdownMenuSeparator, {}), hidden.map((facet) => (_jsx(DropdownMenuItem, { "data-facet-menu-item": facet.columnId, onSelect: () => setRevealed((current) => new Set([...current, facet.columnId])), children: facetTitle(facet) }, facet.columnId)))] })] })) : null;
57
+ const resetNode = isFiltered ? (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
58
+ table.resetColumnFilters();
59
+ table.setGlobalFilter("");
60
+ setRevealed(new Set());
61
+ }, className: "h-8 rounded-xl px-2.5", children: ["Reset", _jsx(X, { className: "size-4" })] })) : null;
62
+ const filtersToggle = canCollapse ? (_jsxs(Button, { type: "button", variant: "outline", size: "sm", "data-filters-toggle": "", "aria-expanded": showFilters, onClick: () => setFiltersOpen((current) => !current), className: cn("h-8 rounded-xl border-border/45 bg-background/65 shadow-none hover:bg-muted/26", activeFilters > 0 && "border-primary/30"), children: [_jsx(SlidersHorizontal, { className: "size-4" }), "Filters", activeFilters > 0 ? (_jsxs("span", { className: "rounded-md bg-muted px-1.5 py-0.5 text-xs font-normal tabular-nums", children: [_jsx("span", { "aria-hidden": "true", children: activeFilters }), _jsxs("span", { className: "sr-only", children: [activeFilters, " active"] })] })) : null] })) : null;
63
+ const rightGroup = actions || viewOptions || focusButton || filtersToggle ? (_jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [filtersToggle, actions, viewOptions ? _jsx(DataTableViewOptions, { table: table }) : null, focusButton] })) : null;
64
+ if (!stacked) {
65
+ return (_jsxs("div", { className: cn("flex items-start gap-2", className), children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-2", children: [searchNode, facetNodes, addFilterNode, children, resetNode] }), rightGroup ? _jsx("div", { className: "ml-auto", children: rightGroup }) : null] }));
66
+ }
67
+ return (_jsxs("div", { className: cn("flex flex-col gap-2", className), children: [_jsxs("div", { className: "flex items-center gap-2", children: [searchNode, rightGroup ? _jsx("div", { className: "ml-auto", children: rightGroup }) : null] }), showFilters ? (_jsxs("div", { "data-filter-row": "", className: "flex flex-wrap items-center gap-2", children: [facetNodes, addFilterNode, children, resetNode] })) : null] }));
18
68
  }
@@ -9,6 +9,15 @@ import { flexRender } from "@tanstack/react-table";
9
9
  import { ChevronDown, ChevronRight } from "lucide-react";
10
10
  import { cn } from "@iloveagents/foundry-web-primitives";
11
11
  const INTERACTIVE = "a, button, input, select, textarea, [role='menuitem'], [data-no-row-click]";
12
+ /** Literal classes so Tailwind generates them: hide the column below the breakpoint. */
13
+ const BREAKPOINT_CLASS = {
14
+ sm: "max-sm:hidden",
15
+ md: "max-md:hidden",
16
+ lg: "max-lg:hidden",
17
+ xl: "max-xl:hidden",
18
+ "2xl": "max-2xl:hidden",
19
+ };
20
+ const breakpointClass = (breakpoint) => breakpoint ? BREAKPOINT_CLASS[breakpoint] : undefined;
12
21
  function orderedRows(table, groupOrder) {
13
22
  const rows = table.getRowModel().rows;
14
23
  if (!table.getState().grouping.length)
@@ -38,7 +47,7 @@ export function DataTable({ table, onRowClick, activeRowId, rowClassName, render
38
47
  };
39
48
  return (_jsx("div", { className: cn("overflow-hidden rounded-2xl border border-border/45 bg-card/60", className), children: _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full caption-bottom text-sm", "aria-label": ariaLabel, children: [_jsx("thead", { className: cn(stickyHeader && "sticky top-0 z-10 bg-card"), children: table.getHeaderGroups().map((headerGroup) => (_jsx("tr", { className: "border-b border-border/45", children: headerGroup.headers.map((header) => {
40
49
  const meta = header.column.columnDef.meta;
41
- return (_jsx("th", { colSpan: header.colSpan, scope: "col", style: header.getSize() !== 150 ? { width: header.getSize() } : undefined, className: cn("h-10 px-3 text-left align-middle text-xs font-medium text-muted-foreground", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", meta?.headerClassName), children: header.isPlaceholder
50
+ return (_jsx("th", { colSpan: header.colSpan, scope: "col", style: header.getSize() !== 150 ? { width: header.getSize() } : undefined, className: cn("h-10 px-3 text-left align-middle text-xs font-medium text-muted-foreground", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", breakpointClass(meta?.breakpoint), meta?.headerClassName), children: header.isPlaceholder
42
51
  ? null
43
52
  : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
44
53
  }) }, headerGroup.id))) }), _jsx("tbody", { children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columnCount, className: "h-24 text-center text-sm text-muted-foreground", children: emptyState }) })) : (rows.map((row) => row.getIsGrouped() ? (_jsx("tr", { "data-group-row": "", className: "border-t border-border/45 bg-muted/35 text-sm font-medium", children: _jsx("td", { colSpan: columnCount, className: "px-3 py-1.5", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "button", onClick: row.getToggleExpandedHandler(), "aria-expanded": row.getIsExpanded(), "aria-label": row.getIsExpanded() ? "Collapse group" : "Expand group", className: "rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground", children: row.getIsExpanded() ? (_jsx(ChevronDown, { className: "size-4" })) : (_jsx(ChevronRight, { className: "size-4" })) }), _jsx("div", { className: "min-w-0 flex-1", children: renderGroup ? renderGroup(row) : _jsx(DefaultGroupLabel, { row: row }) })] }) }) }, row.id)) : (_jsx("tr", { "data-row-id": row.id, "data-state": row.getIsSelected() ? "selected" : undefined, "data-active": activeRowId != null && row.id === activeRowId ? "" : undefined, "aria-current": activeRowId != null && row.id === activeRowId ? "true" : undefined, "aria-selected": row.getIsSelected() || undefined, tabIndex: onRowClick ? 0 : undefined, onClick: (event) => activate(row, event), onKeyDown: (event) => {
@@ -51,7 +60,7 @@ export function DataTable({ table, onRowClick, activeRowId, rowClassName, render
51
60
  }, className: cn("border-t border-border/35 transition-colors", onRowClick &&
52
61
  "cursor-pointer hover:bg-muted/30 focus-visible:bg-muted/30 focus-visible:outline-none", "data-[state=selected]:bg-primary/6 data-[active]:bg-primary/8", rowClassName?.(row)), children: row.getVisibleCells().map((cell) => {
53
62
  const meta = cell.column.columnDef.meta;
54
- return (_jsx("td", { className: cn(cellPad, "align-middle", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", meta?.className), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
63
+ return (_jsx("td", { className: cn(cellPad, "align-middle", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", breakpointClass(meta?.breakpoint), meta?.className), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
55
64
  }) }, row.id)))) })] }) }) }));
56
65
  }
57
66
  function DefaultGroupLabel({ row }) {
@@ -21,12 +21,16 @@ declare module "@tanstack/react-table" {
21
21
  searchText?: (row: TData) => string;
22
22
  /** Labels for facet keys and group headers (`value → label`). */
23
23
  facetLabels?: Record<string, string>;
24
+ /** Smallest viewport the column is shown at; below it the column is hidden (the View menu can still force it). */
25
+ breakpoint?: DataTableBreakpoint;
24
26
  /** Keep unused type parameter referenced for declaration merging. */
25
27
  __cellValue?: TValue;
26
28
  /** Hosts may carry their own keys in `meta` (e.g. a server sort key). */
27
29
  [key: string]: unknown;
28
30
  }
29
31
  }
32
+ /** Tailwind breakpoints a column can be gated on. */
33
+ export type DataTableBreakpoint = "sm" | "md" | "lg" | "xl" | "2xl";
30
34
  /** Facet key for empty cells (`null`, `undefined`, `""`, `[]`). */
31
35
  export declare const FACET_EMPTY = "__none__";
32
36
  export interface DataTableFacetOption {
@@ -19,8 +19,11 @@ export interface DataTableState {
19
19
  }
20
20
  export declare const DEFAULT_PAGE_SIZE = 20;
21
21
  export declare const EMPTY_DATA_TABLE_STATE: DataTableState;
22
- export declare function isDataTableUrlKey(key: string): boolean;
22
+ /** `prefix` namespaces every key (`risks.q`, `risks.f_status`) so two lists can share a page. */
23
+ export declare function isDataTableUrlKey(key: string, prefix?: string): boolean;
23
24
  export interface DataTableUrlOptions {
25
+ /** Key namespace for pages with several lists (`"risks."` → `risks.q`, `risks.f_status`). */
26
+ prefix?: string;
24
27
  /** Page size that is implied when `size` is absent (default 20). */
25
28
  defaultPageSize?: number;
26
29
  /**
@@ -9,9 +9,13 @@ export const EMPTY_DATA_TABLE_STATE = {
9
9
  expanded: true,
10
10
  };
11
11
  const FILTER_PREFIX = "f_";
12
- const OWN_KEYS = new Set(["q", "sort", "hide", "page", "size"]);
13
- export function isDataTableUrlKey(key) {
14
- return OWN_KEYS.has(key) || key.startsWith(FILTER_PREFIX);
12
+ const OWN_KEYS = ["q", "sort", "hide", "page", "size"];
13
+ /** `prefix` namespaces every key (`risks.q`, `risks.f_status`) so two lists can share a page. */
14
+ export function isDataTableUrlKey(key, prefix = "") {
15
+ if (!key.startsWith(prefix))
16
+ return false;
17
+ const bare = key.slice(prefix.length);
18
+ return OWN_KEYS.includes(bare) || bare.startsWith(FILTER_PREFIX);
15
19
  }
16
20
  function encodePart(value) {
17
21
  return value.replace(/%/g, "%25").replace(/,/g, "%2C");
@@ -48,12 +52,13 @@ function hiddenKey(visibility) {
48
52
  */
49
53
  export function serializeDataTableState(state, options) {
50
54
  const defaults = options?.defaults ?? {};
55
+ const prefix = options?.prefix ?? "";
51
56
  const params = new URLSearchParams();
52
57
  if (state.globalFilter !== undefined && state.globalFilter !== (defaults.globalFilter ?? "")) {
53
- params.set("q", state.globalFilter);
58
+ params.set(`${prefix}q`, state.globalFilter);
54
59
  }
55
60
  if (state.sorting !== undefined && sortKey(state.sorting) !== sortKey(defaults.sorting)) {
56
- params.set("sort", sortKey(state.sorting));
61
+ params.set(`${prefix}sort`, sortKey(state.sorting));
57
62
  }
58
63
  if (state.columnFilters !== undefined) {
59
64
  const current = new Map(state.columnFilters.map((f) => [f.id, filterValues(f.value).map(encodePart).join(",")]));
@@ -64,29 +69,32 @@ export function serializeDataTableState(state, options) {
64
69
  for (const id of new Set([...current.keys(), ...base.keys()])) {
65
70
  const value = current.get(id) ?? "";
66
71
  if (value !== (base.get(id) ?? ""))
67
- params.set(`${FILTER_PREFIX}${id}`, value);
72
+ params.set(`${prefix}${FILTER_PREFIX}${id}`, value);
68
73
  }
69
74
  }
70
75
  if (state.columnVisibility !== undefined &&
71
76
  hiddenKey(state.columnVisibility) !== hiddenKey(defaults.columnVisibility)) {
72
- params.set("hide", hiddenKey(state.columnVisibility));
77
+ params.set(`${prefix}hide`, hiddenKey(state.columnVisibility));
73
78
  }
74
79
  const pagination = state.pagination;
75
80
  if (pagination) {
76
81
  const defaultIndex = defaults.pagination?.pageIndex ?? 0;
77
- if (pagination.pageIndex !== defaultIndex)
78
- params.set("page", String(pagination.pageIndex + 1));
82
+ if (pagination.pageIndex !== defaultIndex) {
83
+ params.set(`${prefix}page`, String(pagination.pageIndex + 1));
84
+ }
79
85
  const defaultSize = options?.defaultPageSize ?? defaults.pagination?.pageSize ?? DEFAULT_PAGE_SIZE;
80
86
  if (pagination.pageSize !== defaultSize)
81
- params.set("size", String(pagination.pageSize));
87
+ params.set(`${prefix}size`, String(pagination.pageSize));
82
88
  }
83
89
  return params;
84
90
  }
85
91
  /** Parse a URL into a full state; `defaults` fill what the URL doesn't say. */
86
92
  export function parseDataTableState(params, options) {
87
93
  const defaults = { ...EMPTY_DATA_TABLE_STATE, ...options?.defaults };
94
+ const prefix = options?.prefix ?? "";
95
+ const filterPrefix = `${prefix}${FILTER_PREFIX}`;
88
96
  const defaultSize = options?.defaultPageSize ?? defaults.pagination.pageSize;
89
- const sortRaw = params.get("sort");
97
+ const sortRaw = params.get(`${prefix}sort`);
90
98
  const sorting = sortRaw == null
91
99
  ? defaults.sorting
92
100
  : sortRaw
@@ -97,9 +105,9 @@ export function parseDataTableState(params, options) {
97
105
  : { id: decodePart(part), desc: false });
98
106
  const filtersById = new Map(defaults.columnFilters.map((filter) => [filter.id, filter]));
99
107
  for (const [key, raw] of params.entries()) {
100
- if (!key.startsWith(FILTER_PREFIX))
108
+ if (!key.startsWith(filterPrefix))
101
109
  continue;
102
- const id = key.slice(FILTER_PREFIX.length);
110
+ const id = key.slice(filterPrefix.length);
103
111
  const values = raw.split(",").filter(Boolean).map(decodePart);
104
112
  if (values.length)
105
113
  filtersById.set(id, { id, value: values });
@@ -108,7 +116,7 @@ export function parseDataTableState(params, options) {
108
116
  }
109
117
  const columnFilters = Array.from(filtersById.values());
110
118
  const columnVisibility = { ...defaults.columnVisibility };
111
- const hideRaw = params.get("hide");
119
+ const hideRaw = params.get(`${prefix}hide`);
112
120
  if (hideRaw != null) {
113
121
  for (const id of Object.keys(columnVisibility)) {
114
122
  if (columnVisibility[id] === false)
@@ -117,12 +125,14 @@ export function parseDataTableState(params, options) {
117
125
  for (const id of hideRaw.split(",").filter(Boolean).map(decodePart))
118
126
  columnVisibility[id] = false;
119
127
  }
120
- const pageRaw = params.get("page");
128
+ const pageRaw = params.get(`${prefix}page`);
121
129
  const page = Number.parseInt(pageRaw ?? "", 10);
122
- const size = Number.parseInt(params.get("size") ?? "", 10);
130
+ const size = Number.parseInt(params.get(`${prefix}size`) ?? "", 10);
123
131
  return {
124
132
  ...defaults,
125
- globalFilter: params.has("q") ? (params.get("q") ?? "") : defaults.globalFilter,
133
+ globalFilter: params.has(`${prefix}q`)
134
+ ? (params.get(`${prefix}q`) ?? "")
135
+ : defaults.globalFilter,
126
136
  sorting,
127
137
  columnFilters,
128
138
  columnVisibility,
@@ -139,9 +149,10 @@ export function parseDataTableState(params, options) {
139
149
  /** Replace this table's keys in `params`, leaving every other key untouched. */
140
150
  export function mergeDataTableUrlState(params, state, options) {
141
151
  const next = new URLSearchParams();
142
- for (const [key, value] of params.entries())
143
- if (!isDataTableUrlKey(key))
152
+ for (const [key, value] of params.entries()) {
153
+ if (!isDataTableUrlKey(key, options?.prefix))
144
154
  next.append(key, value);
155
+ }
145
156
  for (const [key, value] of serializeDataTableState(state, options).entries())
146
157
  next.set(key, value);
147
158
  return next;
package/dist/index.d.ts CHANGED
@@ -34,7 +34,8 @@ export { CollectionSkeleton } from "./components/collection-skeleton.js";
34
34
  export { InfiniteScrollSentinel } from "./components/infinite-scroll-sentinel.js";
35
35
  export { CollectionSurface } from "./components/collection-surface.js";
36
36
  export { DataTable, type DataTableProps } from "./components/data-table/data-table.js";
37
- export { DataTableToolbar, type DataTableFacet, type DataTableToolbarProps, } from "./components/data-table/data-table-toolbar.js";
37
+ export { DataTableFrame, useDataTableFrame, type DataTableFrameProps, type DataTableFrameContextValue, } from "./components/data-table/data-table-frame.js";
38
+ export { DataTableToolbar, visibleFacets, type DataTableFacet, type DataTableToolbarLayout, type DataTableToolbarProps, } from "./components/data-table/data-table-toolbar.js";
38
39
  export { DataTableFacetedFilter, type DataTableFacetedFilterProps, } from "./components/data-table/data-table-faceted-filter.js";
39
40
  export { DataTableViewOptions, type DataTableViewOptionsProps, } from "./components/data-table/data-table-view-options.js";
40
41
  export { DataTableColumnHeader, type DataTableColumnHeaderProps, } from "./components/data-table/data-table-column-header.js";
@@ -43,7 +44,7 @@ export { DataTableRowActions, type DataTableRowAction, type DataTableRowActionsP
43
44
  export { selectionColumn, SELECTION_COLUMN_ID } from "./components/data-table/selection-column.js";
44
45
  export { useDataTable, type UseDataTableOptions } from "./components/data-table/use-data-table.js";
45
46
  export { DEFAULT_PAGE_SIZE, EMPTY_DATA_TABLE_STATE, isDataTableUrlKey, mergeDataTableUrlState, parseDataTableState, serializeDataTableState, type DataTableState, type DataTableUrlOptions, } from "./components/data-table/state.js";
46
- export { FACET_EMPTY, cellText, columnLabel, facetCounts, facetFilterFn, facetKeys, facetOptions, normalizeFacetValue, rowSearchText, tokenSearchFilterFn, type DataTableFacetCount, type DataTableFacetOption, } from "./components/data-table/facets.js";
47
+ export { FACET_EMPTY, cellText, columnLabel, facetCounts, facetFilterFn, facetKeys, facetOptions, normalizeFacetValue, rowSearchText, tokenSearchFilterFn, type DataTableBreakpoint, type DataTableFacetCount, type DataTableFacetOption, } from "./components/data-table/facets.js";
47
48
  export { createColumnHelper, flexRender, type Column, type ColumnDef, type ColumnFiltersState, type Row, type RowSelectionState, type SortingState, type Table, type VisibilityState, } from "@tanstack/react-table";
48
49
  export { ThemeRuntimeProvider, ThemeScope, ThemeDocumentMetadata, useThemeRuntime, } from "./components/theme-runtime-provider.js";
49
50
  export { JsonViewer } from "./components/json-viewer.js";
package/dist/index.js CHANGED
@@ -32,7 +32,8 @@ export { CollectionSkeleton } from "./components/collection-skeleton.js";
32
32
  export { InfiniteScrollSentinel } from "./components/infinite-scroll-sentinel.js";
33
33
  export { CollectionSurface } from "./components/collection-surface.js";
34
34
  export { DataTable } from "./components/data-table/data-table.js";
35
- export { DataTableToolbar, } from "./components/data-table/data-table-toolbar.js";
35
+ export { DataTableFrame, useDataTableFrame, } from "./components/data-table/data-table-frame.js";
36
+ export { DataTableToolbar, visibleFacets, } from "./components/data-table/data-table-toolbar.js";
36
37
  export { DataTableFacetedFilter, } from "./components/data-table/data-table-faceted-filter.js";
37
38
  export { DataTableViewOptions, } from "./components/data-table/data-table-view-options.js";
38
39
  export { DataTableColumnHeader, } from "./components/data-table/data-table-column-header.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "license": "MIT",
5
5
  "description": "React agent UI core for Foundry UI — chat, composer, AG-UI adapter for assistant-ui, tool cards, panels, sidebar, theme runtime, and UI stores.",
6
6
  "keywords": [
@@ -71,8 +71,8 @@
71
71
  "react-markdown": "^10.0.0",
72
72
  "remark-gfm": "^4.0.0",
73
73
  "tailwind-merge": "^3.5.0",
74
- "@iloveagents/foundry-agent": "^0.17.0",
75
- "@iloveagents/foundry-web-primitives": "^0.17.0"
74
+ "@iloveagents/foundry-agent": "^0.19.0",
75
+ "@iloveagents/foundry-web-primitives": "^0.19.0"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@ag-ui/client": "^0.0.52",