@iloveagents/foundry-web-ui 0.18.0 → 0.20.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.
Files changed (29) hide show
  1. package/README.md +80 -0
  2. package/dist/components/data-table/data-table-faceted-filter.js +11 -4
  3. package/dist/components/data-table/data-table-frame.d.ts +16 -1
  4. package/dist/components/data-table/data-table-frame.js +32 -7
  5. package/dist/components/data-table/data-table-selection-bar.d.ts +18 -0
  6. package/dist/components/data-table/data-table-selection-bar.js +15 -0
  7. package/dist/components/data-table/data-table-toolbar.d.ts +35 -9
  8. package/dist/components/data-table/data-table-toolbar.js +48 -19
  9. package/dist/components/data-table/data-table-view-options.d.ts +1 -1
  10. package/dist/components/data-table/data-table-view-options.js +1 -1
  11. package/dist/components/data-table/data-table.d.ts +43 -2
  12. package/dist/components/data-table/data-table.js +264 -28
  13. package/dist/components/data-table/date-buckets.d.ts +44 -0
  14. package/dist/components/data-table/date-buckets.js +178 -0
  15. package/dist/components/data-table/facets.d.ts +34 -2
  16. package/dist/components/data-table/facets.js +44 -2
  17. package/dist/components/data-table/use-data-table.d.ts +13 -1
  18. package/dist/components/data-table/use-data-table.js +47 -0
  19. package/dist/components/data-table/use-element-width.d.ts +7 -0
  20. package/dist/components/data-table/use-element-width.js +36 -0
  21. package/dist/components/data-table/use-pinned-offsets.d.ts +9 -0
  22. package/dist/components/data-table/use-pinned-offsets.js +79 -0
  23. package/dist/index.d.ts +7 -3
  24. package/dist/index.js +6 -2
  25. package/dist/lib/app-store.d.ts +11 -1
  26. package/dist/lib/app-store.js +42 -5
  27. package/dist/styles.css +91 -0
  28. package/dist/ui/dropdown-menu.js +1 -1
  29. package/package.json +3 -3
@@ -1,3 +1,16 @@
1
+ /**
2
+ * The list width at which each band starts earning its columns. Measured on
3
+ * the list itself, not the viewport — a list in a side pane is narrow even on
4
+ * a wide screen. Exported so a host that derives breakpoints from a schema
5
+ * measures against the same numbers the table renders by.
6
+ */
7
+ export const DATA_TABLE_MIN_WIDTH = {
8
+ sm: 448,
9
+ md: 672,
10
+ lg: 896,
11
+ xl: 1024,
12
+ "2xl": 1280,
13
+ };
1
14
  /** Facet key for empty cells (`null`, `undefined`, `""`, `[]`). */
2
15
  export const FACET_EMPTY = "__none__";
3
16
  /**
@@ -5,6 +18,16 @@ export const FACET_EMPTY = "__none__";
5
18
  * element. A real value that spells the empty sentinel is escaped with a
6
19
  * leading backslash so it never masquerades as "no value".
7
20
  */
21
+ export function columnFacetKeys(column, value) {
22
+ const buckets = column.columnDef.meta?.facetBuckets;
23
+ if (!buckets)
24
+ return facetKeys(value);
25
+ // Unique, like `facetKeys` is for arrays: a bucket rule that overlaps
26
+ // itself would otherwise count one row twice and report a facet with more
27
+ // matches than there are rows.
28
+ const keys = Array.from(new Set(buckets(value).filter((key) => key !== "")));
29
+ return keys.length ? keys : [FACET_EMPTY];
30
+ }
8
31
  export function facetKeys(value) {
9
32
  if (value == null || value === "")
10
33
  return [FACET_EMPTY];
@@ -21,6 +44,21 @@ export function facetKeys(value) {
21
44
  }
22
45
  return [String(value)];
23
46
  }
47
+ // TanStack memoizes a row's cells, so the array identity is a safe cache key:
48
+ // the id → column map is built once per row instead of scanned once per row
49
+ // per active filter (a scan that is O(columns) on every pass over the data).
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ const rowColumnCache = new WeakMap();
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ function columnOfRow(row, columnId) {
54
+ const cells = row.getAllCells();
55
+ let byId = rowColumnCache.get(cells);
56
+ if (!byId) {
57
+ byId = new Map(cells.map((cell) => [cell.column.id, cell.column]));
58
+ rowColumnCache.set(cells, byId);
59
+ }
60
+ return byId.get(columnId);
61
+ }
24
62
  /** A facet filter value is a list of selected keys; anything else means "no filter". */
25
63
  export function normalizeFacetValue(filterValue) {
26
64
  if (Array.isArray(filterValue))
@@ -39,14 +77,18 @@ export const facetFilterFn = (row, columnId, filterValue) => {
39
77
  const wanted = normalizeFacetValue(filterValue);
40
78
  if (!wanted.length)
41
79
  return true;
42
- return facetKeys(row.getValue(columnId)).some((key) => wanted.includes(key));
80
+ const column = columnOfRow(row, columnId);
81
+ const keys = column
82
+ ? columnFacetKeys(column, row.getValue(columnId))
83
+ : facetKeys(row.getValue(columnId));
84
+ return keys.some((key) => wanted.includes(key));
43
85
  };
44
86
  facetFilterFn.autoRemove = (value) => normalizeFacetValue(value).length === 0;
45
87
  /** Rows per facet key, counted over the rows every OTHER filter lets through. */
46
88
  export function facetCounts(column) {
47
89
  const counts = new Map();
48
90
  for (const row of column.getFacetedRowModel().flatRows) {
49
- for (const key of facetKeys(row.getValue(column.id))) {
91
+ for (const key of columnFacetKeys(column, row.getValue(column.id))) {
50
92
  counts.set(key, (counts.get(key) ?? 0) + 1);
51
93
  }
52
94
  }
@@ -1,4 +1,4 @@
1
- import { type ColumnDef, type FilterFn, type Row, type Table } from "@tanstack/react-table";
1
+ import { type ColumnDef, type FilterFn, type Row, type Table, type VisibilityState } from "@tanstack/react-table";
2
2
  import { type DataTableState } from "./state.js";
3
3
  export interface UseDataTableOptions<T> {
4
4
  data: T[];
@@ -19,4 +19,16 @@ export interface UseDataTableOptions<T> {
19
19
  /** Replace the default AND-of-tokens search. */
20
20
  globalFilterFn?: FilterFn<T>;
21
21
  }
22
+ /**
23
+ * `{ id: false }` for every column whose `meta.defaultHidden` says it starts
24
+ * out of the way — the leaves, since visibility is a leaf property and a
25
+ * grouping header has no state to hide.
26
+ *
27
+ * `useDataTable` applies this to its own initial state. A list whose state
28
+ * lives somewhere else (the URL) must pass it as part of that layer's
29
+ * `defaults` instead: re-applying it on every render would undo the user the
30
+ * moment they bring a column out, because "visible" is exactly what a
31
+ * hidden-only URL does not record.
32
+ */
33
+ export declare function defaultHiddenColumns<T>(columns: readonly ColumnDef<T, any>[]): VisibilityState;
22
34
  export declare function useDataTable<T>(options: UseDataTableOptions<T>): Table<T>;
@@ -9,13 +9,60 @@ import { getCoreRowModel, getExpandedRowModel, getFacetedRowModel, getFacetedUni
9
9
  import { facetFilterFn, tokenSearchFilterFn } from "./facets.js";
10
10
  import { DEFAULT_PAGE_SIZE, EMPTY_DATA_TABLE_STATE } from "./state.js";
11
11
  const PAGE_RESET_KEYS = new Set(["globalFilter", "columnFilters", "sorting"]);
12
+ /**
13
+ * TanStack's own rule for a column's id, mirrored so `meta.defaultHidden` can
14
+ * be resolved before the table exists: an explicit `id`, else the
15
+ * `accessorKey` with EVERY dot turned into an underscore (`user.profile.name`
16
+ * → `user_profile_name`), else a string header. Anything else has no stable
17
+ * id to key visibility by.
18
+ */
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ function columnId(column) {
21
+ if (column.id)
22
+ return column.id;
23
+ const accessorKey = "accessorKey" in column ? column.accessorKey : undefined;
24
+ if (accessorKey != null)
25
+ return String(accessorKey).replace(/\./g, "_");
26
+ return typeof column.header === "string" ? column.header : undefined;
27
+ }
28
+ /**
29
+ * `{ id: false }` for every column whose `meta.defaultHidden` says it starts
30
+ * out of the way — the leaves, since visibility is a leaf property and a
31
+ * grouping header has no state to hide.
32
+ *
33
+ * `useDataTable` applies this to its own initial state. A list whose state
34
+ * lives somewhere else (the URL) must pass it as part of that layer's
35
+ * `defaults` instead: re-applying it on every render would undo the user the
36
+ * moment they bring a column out, because "visible" is exactly what a
37
+ * hidden-only URL does not record.
38
+ */
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ export function defaultHiddenColumns(columns) {
41
+ const hidden = {};
42
+ const walk = (defs) => {
43
+ for (const column of defs) {
44
+ const children = "columns" in column ? column.columns : undefined;
45
+ if (children?.length) {
46
+ walk(children);
47
+ continue;
48
+ }
49
+ const id = columnId(column);
50
+ if (id && column.meta?.defaultHidden)
51
+ hidden[id] = false;
52
+ }
53
+ };
54
+ walk(columns);
55
+ return hidden;
56
+ }
12
57
  export function useDataTable(options) {
13
58
  const { data, columns, getRowId, state: controlledState, onStateChange, initialState, pageSize, groupBy, enableRowSelection = false, enableMultiRowSelection = true, globalFilterFn, } = options;
14
59
  const controlled = controlledState !== undefined && onStateChange !== undefined;
15
60
  const basePageSize = pageSize || DEFAULT_PAGE_SIZE;
61
+ const defaultHidden = useMemo(() => defaultHiddenColumns(columns), [columns]);
16
62
  const [innerState, setInnerState] = useState(() => ({
17
63
  ...EMPTY_DATA_TABLE_STATE,
18
64
  ...initialState,
65
+ columnVisibility: { ...defaultHidden, ...initialState?.columnVisibility },
19
66
  pagination: { pageIndex: 0, pageSize: basePageSize, ...initialState?.pagination },
20
67
  }));
21
68
  const state = useMemo(() => controlled
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The measured width of an element, or `undefined` until it has one. Lists
3
+ * use it to decide which columns fit: a table beside an open detail pane is
4
+ * narrower than the window, and only the table knows by how much.
5
+ */
6
+ import { type RefObject } from "react";
7
+ export declare function useElementWidth(ref: RefObject<HTMLElement | null>): number | undefined;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The measured width of an element, or `undefined` until it has one. Lists
3
+ * use it to decide which columns fit: a table beside an open detail pane is
4
+ * narrower than the window, and only the table knows by how much.
5
+ */
6
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
7
+ export function useElementWidth(ref) {
8
+ const [width, setWidth] = useState(undefined);
9
+ // The ref OBJECT is stable while the node it holds may not be: a caller
10
+ // that swaps the element keeps the same ref, and an observer bound once
11
+ // would go on measuring the detached node forever. So the element itself
12
+ // is state, refreshed after every render that changed it.
13
+ const [element, setElement] = useState(null);
14
+ const seen = useRef(null);
15
+ useLayoutEffect(() => {
16
+ if (ref.current === seen.current)
17
+ return;
18
+ seen.current = ref.current;
19
+ setElement(ref.current);
20
+ });
21
+ useEffect(() => {
22
+ if (!element || typeof ResizeObserver === "undefined")
23
+ return;
24
+ const read = () => {
25
+ const next = element.getBoundingClientRect().width;
26
+ // A zero width means "not laid out yet" (or a test environment); treat
27
+ // it as unmeasured so nothing is hidden on a guess.
28
+ setWidth((current) => (next > 0 && Math.abs((current ?? -1) - next) > 0.5 ? next : current));
29
+ };
30
+ const observer = new ResizeObserver(read);
31
+ observer.observe(element);
32
+ read();
33
+ return () => observer.disconnect();
34
+ }, [element]);
35
+ return width;
36
+ }
@@ -0,0 +1,9 @@
1
+ export interface PinnedOffsets {
2
+ /** `columnId → left offset in px`, for the columns that are pinned. */
3
+ offsets: Record<string, number>;
4
+ /** The id of the last pinned column, which carries the edge shadow. */
5
+ lastPinned: string | null;
6
+ /** Attach to each pinned header cell so its width is tracked. */
7
+ measure: (columnId: string) => (element: HTMLElement | null) => void;
8
+ }
9
+ export declare function usePinnedOffsets(pinnedIds: readonly string[]): PinnedOffsets;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Left offsets for pinned columns. A pinned column sticks to the left edge
3
+ * of the scroll container; the ones after it stack behind their predecessors,
4
+ * so each needs the summed width of the pinned columns before it. Widths are
5
+ * measured from the rendered header cells (a column's declared `size` is a
6
+ * hint, not the used width) and kept current with a `ResizeObserver`.
7
+ */
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ export function usePinnedOffsets(pinnedIds) {
10
+ const elements = useRef(new Map());
11
+ const [widths, setWidths] = useState({});
12
+ const key = pinnedIds.join(",");
13
+ const read = useCallback(() => {
14
+ setWidths((current) => {
15
+ let changed = false;
16
+ const next = {};
17
+ for (const [id, element] of elements.current) {
18
+ const width = element.getBoundingClientRect().width;
19
+ next[id] = width;
20
+ if (Math.abs((current[id] ?? -1) - width) > 0.5)
21
+ changed = true;
22
+ }
23
+ if (!changed && Object.keys(next).length === Object.keys(current).length)
24
+ return current;
25
+ return next;
26
+ });
27
+ }, []);
28
+ const observerRef = useRef(null);
29
+ useEffect(() => {
30
+ if (typeof ResizeObserver === "undefined")
31
+ return;
32
+ const observer = new ResizeObserver(read);
33
+ observerRef.current = observer;
34
+ for (const element of elements.current.values())
35
+ observer.observe(element);
36
+ read();
37
+ return () => {
38
+ observer.disconnect();
39
+ observerRef.current = null;
40
+ };
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ }, [key, read]);
43
+ // One stable callback per column: a fresh ref callback on every render
44
+ // would make React detach and re-attach the ref each time.
45
+ const callbacks = useRef(new Map());
46
+ const measure = useCallback((columnId) => {
47
+ const existing = callbacks.current.get(columnId);
48
+ if (existing)
49
+ return existing;
50
+ const callback = (element) => {
51
+ const previous = elements.current.get(columnId);
52
+ if (previous === element)
53
+ return;
54
+ if (previous)
55
+ observerRef.current?.unobserve(previous);
56
+ if (element) {
57
+ elements.current.set(columnId, element);
58
+ observerRef.current?.observe(element);
59
+ }
60
+ else {
61
+ elements.current.delete(columnId);
62
+ }
63
+ read();
64
+ };
65
+ callbacks.current.set(columnId, callback);
66
+ return callback;
67
+ }, [read]);
68
+ const offsets = {};
69
+ let running = 0;
70
+ for (const id of pinnedIds) {
71
+ offsets[id] = running;
72
+ running += widths[id] ?? 0;
73
+ }
74
+ return {
75
+ offsets,
76
+ lastPinned: pinnedIds.length ? pinnedIds[pinnedIds.length - 1] : null,
77
+ measure,
78
+ };
79
+ }
package/dist/index.d.ts CHANGED
@@ -35,16 +35,20 @@ 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
37
  export { DataTableFrame, useDataTableFrame, type DataTableFrameProps, type DataTableFrameContextValue, } from "./components/data-table/data-table-frame.js";
38
- export { DataTableToolbar, visibleFacets, type DataTableFacet, type DataTableToolbarProps, } from "./components/data-table/data-table-toolbar.js";
38
+ export { DataTableSelectionBar, type DataTableSelectionBarProps, } from "./components/data-table/data-table-selection-bar.js";
39
+ export { DataTableToolbar, visibleFacets, type DataTableFacet, type DataTableToolbarLayout, type DataTableToolbarProps, } from "./components/data-table/data-table-toolbar.js";
39
40
  export { DataTableFacetedFilter, type DataTableFacetedFilterProps, } from "./components/data-table/data-table-faceted-filter.js";
40
41
  export { DataTableViewOptions, type DataTableViewOptionsProps, } from "./components/data-table/data-table-view-options.js";
41
42
  export { DataTableColumnHeader, type DataTableColumnHeaderProps, } from "./components/data-table/data-table-column-header.js";
42
43
  export { DataTablePagination, type DataTablePaginationProps, } from "./components/data-table/data-table-pagination.js";
43
44
  export { DataTableRowActions, type DataTableRowAction, type DataTableRowActionsProps, } from "./components/data-table/data-table-row-actions.js";
44
45
  export { selectionColumn, SELECTION_COLUMN_ID } from "./components/data-table/selection-column.js";
45
- export { useDataTable, type UseDataTableOptions } from "./components/data-table/use-data-table.js";
46
+ export { useDataTable, defaultHiddenColumns, type UseDataTableOptions, } from "./components/data-table/use-data-table.js";
47
+ export { usePinnedOffsets, type PinnedOffsets, } from "./components/data-table/use-pinned-offsets.js";
48
+ export { useElementWidth } from "./components/data-table/use-element-width.js";
46
49
  export { DEFAULT_PAGE_SIZE, EMPTY_DATA_TABLE_STATE, isDataTableUrlKey, mergeDataTableUrlState, parseDataTableState, serializeDataTableState, type DataTableState, type DataTableUrlOptions, } from "./components/data-table/state.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";
50
+ export { ageBucket, ageBuckets, dueState, parseDateish, relativeAge, type AgeBucket, type DateBucketOptions, type DueState, type DueStateOptions, } from "./components/data-table/date-buckets.js";
51
+ export { DATA_TABLE_MIN_WIDTH, FACET_EMPTY, cellText, columnFacetKeys, columnLabel, facetCounts, facetFilterFn, facetKeys, facetOptions, normalizeFacetValue, rowSearchText, tokenSearchFilterFn, type DataTableBreakpoint, type DataTableFacetCount, type DataTableFacetOption, } from "./components/data-table/facets.js";
48
52
  export { createColumnHelper, flexRender, type Column, type ColumnDef, type ColumnFiltersState, type Row, type RowSelectionState, type SortingState, type Table, type VisibilityState, } from "@tanstack/react-table";
49
53
  export { ThemeRuntimeProvider, ThemeScope, ThemeDocumentMetadata, useThemeRuntime, } from "./components/theme-runtime-provider.js";
50
54
  export { JsonViewer } from "./components/json-viewer.js";
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ 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
35
  export { DataTableFrame, useDataTableFrame, } from "./components/data-table/data-table-frame.js";
36
+ export { DataTableSelectionBar, } from "./components/data-table/data-table-selection-bar.js";
36
37
  export { DataTableToolbar, visibleFacets, } from "./components/data-table/data-table-toolbar.js";
37
38
  export { DataTableFacetedFilter, } from "./components/data-table/data-table-faceted-filter.js";
38
39
  export { DataTableViewOptions, } from "./components/data-table/data-table-view-options.js";
@@ -40,9 +41,12 @@ export { DataTableColumnHeader, } from "./components/data-table/data-table-colum
40
41
  export { DataTablePagination, } from "./components/data-table/data-table-pagination.js";
41
42
  export { DataTableRowActions, } from "./components/data-table/data-table-row-actions.js";
42
43
  export { selectionColumn, SELECTION_COLUMN_ID } from "./components/data-table/selection-column.js";
43
- export { useDataTable } from "./components/data-table/use-data-table.js";
44
+ export { useDataTable, defaultHiddenColumns, } from "./components/data-table/use-data-table.js";
45
+ export { usePinnedOffsets, } from "./components/data-table/use-pinned-offsets.js";
46
+ export { useElementWidth } from "./components/data-table/use-element-width.js";
44
47
  export { DEFAULT_PAGE_SIZE, EMPTY_DATA_TABLE_STATE, isDataTableUrlKey, mergeDataTableUrlState, parseDataTableState, serializeDataTableState, } from "./components/data-table/state.js";
45
- export { FACET_EMPTY, cellText, columnLabel, facetCounts, facetFilterFn, facetKeys, facetOptions, normalizeFacetValue, rowSearchText, tokenSearchFilterFn, } from "./components/data-table/facets.js";
48
+ export { ageBucket, ageBuckets, dueState, parseDateish, relativeAge, } from "./components/data-table/date-buckets.js";
49
+ export { DATA_TABLE_MIN_WIDTH, FACET_EMPTY, cellText, columnFacetKeys, columnLabel, facetCounts, facetFilterFn, facetKeys, facetOptions, normalizeFacetValue, rowSearchText, tokenSearchFilterFn, } from "./components/data-table/facets.js";
46
50
  export { createColumnHelper, flexRender, } from "@tanstack/react-table";
47
51
  export { ThemeRuntimeProvider, ThemeScope, ThemeDocumentMetadata, useThemeRuntime, } from "./components/theme-runtime-provider.js";
48
52
  export { JsonViewer } from "./components/json-viewer.js";
@@ -51,6 +51,8 @@ export type ContextPayload = TextPayload | PagePayload | ReferencePayload;
51
51
  export type ContextItemType = "selection" | "page" | "ref" | "note";
52
52
  export interface ContextItem {
53
53
  id: string;
54
+ /** Set for context that tracks the UI (a list's selection): one item per key. */
55
+ key?: string;
54
56
  type: ContextItemType;
55
57
  label: string;
56
58
  payload: ContextPayload;
@@ -103,7 +105,15 @@ interface AppState {
103
105
  clearHighlights: () => void;
104
106
  contextItems: ContextItem[];
105
107
  sentContext: Record<string, ContextItem[]>;
106
- addContextItem: (item: Omit<ContextItem, "id" | "createdAt">) => void;
108
+ /** Context the user pinned. Keyed (UI-tracked) items go through
109
+ * `setContextItem`, which is the only thing that replaces by key. */
110
+ addContextItem: (item: Omit<ContextItem, "id" | "createdAt" | "key">) => void;
111
+ /**
112
+ * Context that tracks a piece of UI rather than a user's pin — the rows
113
+ * selected in a list, say. One item per `key`: setting replaces, `null`
114
+ * clears. Without this, a chip that changes with the UI would pile up.
115
+ */
116
+ setContextItem: (key: string, item: Omit<ContextItem, "id" | "createdAt"> | null) => void;
107
117
  removeContextItem: (id: string) => void;
108
118
  promoteContextItem: (id: string) => void;
109
119
  clearAllContext: () => void;
@@ -60,10 +60,15 @@ export const useAppStore = create((set, get) => ({
60
60
  const prev = get().currentPage;
61
61
  if (prev === page)
62
62
  return;
63
- // Clear ephemeral context on navigation
63
+ // Clear ephemeral context on navigation — and every KEYED item, whatever
64
+ // its persistence: those track a piece of UI, and that UI is gone. A pin
65
+ // the user made survives, because promoting an item drops its key.
64
66
  set({
65
67
  currentPage: page,
66
- contextItems: get().contextItems.filter((i) => i.persistence !== "ephemeral"),
68
+ contextItems: get().contextItems.filter(
69
+ // `key === undefined`, not falsy: "" is a key someone chose, and a
70
+ // keyed item tracks UI that this navigation just unmounted.
71
+ (i) => i.persistence !== "ephemeral" && i.key === undefined),
67
72
  });
68
73
  },
69
74
  // Navigation context
@@ -118,9 +123,36 @@ export const useAppStore = create((set, get) => ({
118
123
  ],
119
124
  });
120
125
  },
126
+ setContextItem: (key, item) => {
127
+ const withoutKey = get().contextItems.filter((existing) => existing.key !== key);
128
+ if (!item) {
129
+ if (withoutKey.length !== get().contextItems.length)
130
+ set({ contextItems: withoutKey });
131
+ return;
132
+ }
133
+ if (withoutKey.length >= MAX_ITEMS)
134
+ return;
135
+ const label = item.label.length > MAX_LABEL_LENGTH
136
+ ? `${item.label.slice(0, MAX_LABEL_LENGTH)}...`
137
+ : item.label;
138
+ set({
139
+ contextItems: [
140
+ ...withoutKey,
141
+ { ...item, key, label, id: crypto.randomUUID(), createdAt: Date.now() },
142
+ ],
143
+ });
144
+ },
121
145
  removeContextItem: (id) => set({ contextItems: get().contextItems.filter((i) => i.id !== id) }),
122
146
  promoteContextItem: (id) => set({
123
- contextItems: get().contextItems.map((i) => i.id === id ? { ...i, persistence: "persistent" } : i),
147
+ contextItems: get().contextItems.map((i) => {
148
+ if (i.id !== id)
149
+ return i;
150
+ // Pinning takes the item away from the UI that was tracking it: the
151
+ // key goes, so the next selection change replaces nothing and the
152
+ // pin the user made survives.
153
+ const { key: _tracked, ...rest } = i;
154
+ return { ...rest, persistence: "persistent" };
155
+ }),
124
156
  }),
125
157
  clearAllContext: () => set({ contextItems: [], sentContext: {} }),
126
158
  consumeContextForMessage: (messageId) => {
@@ -128,9 +160,14 @@ export const useAppStore = create((set, get) => ({
128
160
  if (contextItems.length === 0)
129
161
  return [];
130
162
  const snapshot = [...contextItems];
131
- // Keep persistent, remove ephemeral
163
+ // Keep persistent, remove ephemeral — except context that TRACKS the UI
164
+ // (a keyed item, e.g. a live list selection). Those rows are still
165
+ // ticked after the message goes; dropping the chip would take them out
166
+ // of every later message while the user can still see them selected.
167
+ // Their owner clears them (`setContextItem(key, null)`), and navigation
168
+ // still does.
132
169
  set({
133
- contextItems: contextItems.filter((i) => i.persistence === "persistent"),
170
+ contextItems: contextItems.filter((i) => i.persistence === "persistent" || i.key !== undefined),
134
171
  sentContext: { ...sentContext, [messageId]: snapshot },
135
172
  });
136
173
  return snapshot;
package/dist/styles.css CHANGED
@@ -140,3 +140,94 @@
140
140
  --code-block: oklch(0.16 0.01 286);
141
141
  --code-block-foreground: oklch(0.9 0.01 286);
142
142
  }
143
+
144
+ /* ---------------------------------------------------------------- lists --
145
+ * Rules the components need but a host's CSS scanner cannot infer, so they
146
+ * ship here. Import this stylesheet (see the README) to get them.
147
+ */
148
+
149
+ /* A long menu — every column of a wide list — stays reachable: capped to the
150
+ * room the trigger has and scrollable inside it. */
151
+ [data-menu-scroll] {
152
+ max-height: var(--radix-dropdown-menu-content-available-height);
153
+ overflow-y: auto;
154
+ overscroll-behavior: contain;
155
+ }
156
+
157
+ /* Frozen columns. The cell has to be opaque, since the other columns scroll
158
+ * underneath it, so the row's translucent states are restated opaquely. */
159
+ [data-pinned] {
160
+ position: sticky;
161
+ }
162
+ /* The frozen column slides under the sticky header, never over it: the header
163
+ * row carries z-10 (see `DataTable`), so a frozen body cell has to sit below
164
+ * that, and the frozen header cell only has to beat its own row. */
165
+ [data-pinned="header"] {
166
+ z-index: 2;
167
+ background-color: var(--card);
168
+ }
169
+ /* An opaque stand-in for the table's own surface (`bg-card/60` over the
170
+ * page), not the page background — otherwise the frozen strip reads as a
171
+ * different colour from the rows it belongs to, most visibly in dark mode. */
172
+ [data-pinned="cell"] {
173
+ z-index: 1;
174
+ /* The opaque equal of the table's own `bg-card/60`. Every row state below
175
+ * mixes its tint into THIS, the way a translucent tint on an ordinary cell
176
+ * lands on the table surface rather than on the page. */
177
+ --pinned-surface: color-mix(in srgb, var(--card) 60%, var(--background));
178
+ background-color: var(--pinned-surface);
179
+ }
180
+ /* The seam says "there is more to the left" — so it appears only once the
181
+ * list is actually scrolled sideways, and stays quiet otherwise. */
182
+ [data-scrolled-x] [data-pinned-edge] {
183
+ border-right: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
184
+ box-shadow: 6px 0 6px -6px color-mix(in srgb, var(--foreground) 12%, transparent);
185
+ }
186
+
187
+ /* A list you can scroll should say so: keep its scrollbars visible rather
188
+ * than relying on the overlay ones macOS hides until you already scroll. */
189
+ [data-list-scroller] {
190
+ scrollbar-gutter: stable;
191
+ scrollbar-width: thin;
192
+ scrollbar-color: color-mix(in srgb, var(--foreground) 25%, transparent) transparent;
193
+ }
194
+ [data-list-scroller]::-webkit-scrollbar {
195
+ width: 10px;
196
+ height: 10px;
197
+ }
198
+ [data-list-scroller]::-webkit-scrollbar-track {
199
+ background: transparent;
200
+ }
201
+ [data-list-scroller]::-webkit-scrollbar-thumb {
202
+ border: 3px solid transparent;
203
+ border-radius: 999px;
204
+ background-clip: content-box;
205
+ background-color: color-mix(in srgb, var(--foreground) 22%, transparent);
206
+ }
207
+ [data-list-scroller]:hover::-webkit-scrollbar-thumb {
208
+ background-color: color-mix(in srgb, var(--foreground) 32%, transparent);
209
+ }
210
+
211
+ /* A group header spans the scrollable width; its content sticks to the
212
+ * viewport so the label and total stay readable while columns scroll. */
213
+ [data-group-content] {
214
+ position: sticky;
215
+ left: 0;
216
+ }
217
+ /* Only rows that actually respond to a hover — `DataTable` marks those —
218
+ * so a read-only row never lights up its frozen half alone. */
219
+ tr[data-clickable]:hover > [data-pinned="cell"] {
220
+ background-color: color-mix(in srgb, var(--muted) 30%, var(--pinned-surface));
221
+ }
222
+ /* Only when the ROW itself is focused. A control inside a cell does not tint
223
+ * the ordinary cells, so tinting the frozen ones would make the strip diverge
224
+ * exactly while someone is using that control. */
225
+ tr[data-focusable]:focus-visible > [data-pinned="cell"] {
226
+ background-color: color-mix(in srgb, var(--muted) 30%, var(--pinned-surface));
227
+ }
228
+ tr[data-state="selected"] > [data-pinned="cell"] {
229
+ background-color: color-mix(in srgb, var(--primary) 6%, var(--pinned-surface));
230
+ }
231
+ tr[data-active] > [data-pinned="cell"] {
232
+ background-color: color-mix(in srgb, var(--primary) 8%, var(--pinned-surface));
233
+ }
@@ -5,7 +5,7 @@ import { Check } from "lucide-react";
5
5
  import { cn } from "@iloveagents/foundry-web-primitives";
6
6
  export const DropdownMenu = DropdownMenuPrimitive.Root;
7
7
  export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
8
- export const DropdownMenuContent = forwardRef(({ className, sideOffset = 4, ...props }, ref) => (_jsx(DropdownMenuPrimitive.Portal, { children: _jsx(DropdownMenuPrimitive.Content, { ref: ref, sideOffset: sideOffset, className: cn("z-50 min-w-[8rem] overflow-hidden rounded-md border border-border", "bg-popover p-1 text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props }) })));
8
+ export const DropdownMenuContent = forwardRef(({ className, sideOffset = 4, ...props }, ref) => (_jsx(DropdownMenuPrimitive.Portal, { children: _jsx(DropdownMenuPrimitive.Content, { ref: ref, sideOffset: sideOffset, "data-menu-scroll": "", className: cn("z-50 min-w-[8rem] rounded-md border border-border", "bg-popover p-1 text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props }) })));
9
9
  DropdownMenuContent.displayName = "DropdownMenuContent";
10
10
  export const DropdownMenuItem = forwardRef(({ className, ...props }, ref) => (_jsx(DropdownMenuPrimitive.Item, { ref: ref, className: cn(
11
11
  // ``gap-2`` is the same icon-to-text spacing the framework's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.18.0",
3
+ "version": "0.20.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.18.0",
75
- "@iloveagents/foundry-web-primitives": "^0.18.0"
74
+ "@iloveagents/foundry-agent": "^0.20.0",
75
+ "@iloveagents/foundry-web-primitives": "^0.20.0"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@ag-ui/client": "^0.0.52",