@iloveagents/foundry-web-ui 0.21.0 → 0.21.1

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.
@@ -5,7 +5,8 @@
5
5
  * habit across the platform (and the same set the chat agent reads).
6
6
  */
7
7
  import type { Row, Table } from "@tanstack/react-table";
8
- import type { ReactNode } from "react";
8
+ import { type ReactNode } from "react";
9
+ export declare const SelectionBarSingleLine: import("react").Provider<boolean>;
9
10
  export interface DataTableSelectionBarProps<T> {
10
11
  table: Table<T>;
11
12
  /** Bulk actions for the selected rows. */
@@ -1,14 +1,34 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { X } from "lucide-react";
3
+ import { createContext, useContext } from "react";
3
4
  import { Button, cn } from "@iloveagents/foundry-web-primitives";
5
+ /**
6
+ * Whether the bar has a row to itself or shares one.
7
+ *
8
+ * The toolbar's non-stacked layout puts the bar on the SAME line as the
9
+ * search, in a region that scrolls sideways rather than growing — because a
10
+ * toolbar that gains height on the first tick shoves the table down under
11
+ * the cursor mid-click, which is the whole reason the bar moved into the
12
+ * toolbar. A bar that wraps internally defeats that just as thoroughly as
13
+ * one that wraps externally, and its nested actions container is a second
14
+ * place it can happen (review catch).
15
+ *
16
+ * The container knows the constraint and the bar owns the class names, so
17
+ * the policy travels between them instead of the toolbar reaching in with a
18
+ * descendant selector — which would apply to a host's own `selectionBar`
19
+ * node too, and lose to it on specificity besides.
20
+ */
21
+ const SelectionBarSingleLineContext = createContext(false);
22
+ export const SelectionBarSingleLine = SelectionBarSingleLineContext.Provider;
4
23
  export function DataTableSelectionBar({ table, children, label = "row", plural, className, }) {
5
24
  // Every selected row, not only the ones the current filter shows: a
6
25
  // selection the user cannot see is still a selection, and hiding the bar
7
26
  // would take away the only way to clear it.
8
27
  const rows = table.getSelectedRowModel().rows;
28
+ const singleLine = useContext(SelectionBarSingleLineContext);
9
29
  if (rows.length === 0)
10
30
  return null;
11
- return (_jsxs("div", { "data-selection-bar": "", role: "status", className: cn("flex min-h-8 flex-wrap items-center gap-2 rounded-xl bg-primary/5 px-3 text-sm", className), children: [_jsxs("span", { className: "font-medium", children: [rows.length, " ", rows.length === 1 ? label : (plural ?? `${label}s`), " selected"] }), _jsx("div", { className: "flex flex-wrap items-center gap-2", children: typeof children === "function" ? children(rows) : children }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm",
31
+ return (_jsxs("div", { "data-selection-bar": "", role: "status", className: cn("flex min-h-8 items-center gap-2 rounded-xl bg-primary/5 px-3 text-sm", singleLine ? "flex-nowrap whitespace-nowrap" : "flex-wrap", className), children: [_jsxs("span", { className: "font-medium", children: [rows.length, " ", rows.length === 1 ? label : (plural ?? `${label}s`), " selected"] }), _jsx("div", { "data-selection-actions": "", className: cn("flex items-center gap-2", singleLine ? "flex-nowrap" : "flex-wrap"), children: typeof children === "function" ? children(rows) : children }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm",
12
32
  // `true` = the blank state, not the caller's `initialState`, which
13
33
  // would make "Clear" resurrect rows.
14
34
  onClick: () => table.resetRowSelection(true), className: "ml-auto h-8 rounded-lg px-2.5", children: ["Clear", _jsx(X, { className: "size-4" })] })] }));
@@ -1,9 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Maximize2, Minimize2, Plus, Search, SlidersHorizontal, X } from "lucide-react";
3
3
  import { useState } from "react";
4
4
  import { Button, Input, cn } from "@iloveagents/foundry-web-primitives";
5
5
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../ui/dropdown-menu.js";
6
6
  import { DataTableFacetedFilter } from "./data-table-faceted-filter.js";
7
+ import { SelectionBarSingleLine } from "./data-table-selection-bar.js";
7
8
  import { useDataTableFrame } from "./data-table-frame.js";
8
9
  import { DataTableViewOptions } from "./data-table-view-options.js";
9
10
  import { columnLabel, normalizeFacetValue } from "./facets.js";
@@ -75,7 +76,32 @@ export function DataTableToolbar({ table, facets = [], layout = "auto", collapsi
75
76
  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;
76
77
  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;
77
78
  if (!stacked) {
78
- return (_jsxs("div", { className: cn("flex flex-col gap-2", className), children: [_jsxs("div", { className: "flex items-start gap-2", 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] }), showSelectionBar ? _jsx("div", { "data-selection-row": "", children: selectionBar }) : null] }));
79
+ return (
80
+ // A stable hook for tests and hosts, rather than leaving them to
81
+ // match on Tailwind classes — a selector that silently stops
82
+ // matching is how an assertion starts passing for the wrong reason
83
+ // (review catch).
84
+ _jsx("div", { "data-slot": "data-table-toolbar", className: cn("flex flex-col gap-2", className), children: _jsxs("div", { className: "flex items-start gap-2", children: [_jsxs("div", { "data-toolbar-lead": showSelectionBar ? "selection" : "filters", className: cn("flex min-w-0 flex-1 items-center gap-2",
85
+ // Facets genuinely want rows: six of them in a narrow pane
86
+ // should stack rather than scroll off the side. A selection
87
+ // bar is the opposite — one row by definition. Left wrapping,
88
+ // it drops below the search as soon as the search alone is
89
+ // wider than the space, which grows the toolbar again and is
90
+ // the jump this whole change exists to remove (review catch).
91
+ showSelectionBar ? "flex-nowrap" : "flex-wrap"), children: [searchNode, showSelectionBar ? (
92
+ // The bar shares this row with a search that has a width
93
+ // floor and a right group that does not shrink, so in a
94
+ // narrow pane it gets squeezed. It scrolls rather than
95
+ // wrapping inside itself — the row stays exactly one high.
96
+ //
97
+ // And the scrollbar itself stays out of layout. A classic,
98
+ // space-consuming one — Windows and Linux by default, macOS
99
+ // set to "always show" — appears only once the bar overflows,
100
+ // which is only once something is ticked, so its height lands
101
+ // on the toolbar as the same jump by another route (review
102
+ // catch). `[data-selection-row]` in `styles.css` keeps it out
103
+ // of layout; hiding the widget does not hide the scrolling.
104
+ _jsx("div", { "data-selection-row": "", className: "min-w-0 flex-1 overflow-x-auto", children: _jsx(SelectionBarSingleLine, { value: true, children: selectionBar }) })) : (_jsxs(_Fragment, { children: [facetNodes, addFilterNode, children, resetNode] }))] }), rightGroup ? _jsx("div", { className: "ml-auto", children: rightGroup }) : null] }) }));
79
105
  }
80
- 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] }), showSelectionBar ? (_jsx("div", { "data-selection-row": "", children: selectionBar })) : showFilters ? (_jsxs("div", { "data-filter-row": "", className: "flex flex-wrap items-center gap-2", children: [facetNodes, addFilterNode, children, resetNode] })) : null] }));
106
+ return (_jsxs("div", { "data-slot": "data-table-toolbar", 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] }), showSelectionBar ? (_jsx("div", { "data-selection-row": "", children: selectionBar })) : showFilters ? (_jsxs("div", { "data-filter-row": "", className: "flex flex-wrap items-center gap-2", children: [facetNodes, addFilterNode, children, resetNode] })) : null] }));
81
107
  }
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
3
3
  import { resolveThemeRuntime, } from "../lib/theme-runtime.js";
4
4
  const ThemeRuntimeContext = createContext(null);
5
+ /** True inside a ThemeScope — the outermost one owns the document. */
6
+ const ThemeScopeNestedContext = createContext(false);
5
7
  export function ThemeRuntimeProvider({ layers, mode, children, }) {
6
8
  const fallback = useRef(resolveThemeRuntime([], "system"));
7
9
  const [systemPrefersDark, setSystemPrefersDark] = useState(() => typeof window !== "undefined"
@@ -33,9 +35,149 @@ export function ThemeRuntimeProvider({ layers, mode, children, }) {
33
35
  export function useThemeRuntime() {
34
36
  return useContext(ThemeRuntimeContext) ?? resolveThemeRuntime([], "system");
35
37
  }
38
+ const rootThemeStack = [];
39
+ const rootThemeBase = new Map();
40
+ // What this module last put on the element, per name. Anything else there
41
+ // now came from outside, and outside wins — see `applyTopRootTheme`. Value
42
+ // AND priority: a host re-declaring the same value as `!important` has said
43
+ // something new about that name, and comparing values alone would read it as
44
+ // our own write and quietly drop the flag (review catch).
45
+ const rootThemeWritten = new Map();
46
+ function currentlyOurs(root, name) {
47
+ const written = rootThemeWritten.get(name);
48
+ if (written === undefined)
49
+ return true;
50
+ return (root.style.getPropertyValue(name) === written.value
51
+ && root.style.getPropertyPriority(name) === written.priority);
52
+ }
53
+ function rememberWrite(root, name) {
54
+ // Read back rather than storing what we passed: the browser normalises the
55
+ // value, and a comparison against the un-normalised string would read
56
+ // every one of our own writes as somebody else's.
57
+ rootThemeWritten.set(name, {
58
+ value: root.style.getPropertyValue(name),
59
+ priority: root.style.getPropertyPriority(name),
60
+ });
61
+ }
62
+ function applyTopRootTheme(root) {
63
+ const top = rootThemeStack[rootThemeStack.length - 1];
64
+ for (const [name, base] of rootThemeBase) {
65
+ if (!currentlyOurs(root, name)) {
66
+ // Someone outside this module wrote this name after we did — a host
67
+ // theme manager, a design-mode preview, a test. Their write is newer
68
+ // than anything we have to say about it, so we neither overwrite it
69
+ // now nor restore over it later: the name is theirs from here
70
+ // (review catch).
71
+ continue;
72
+ }
73
+ const next = top?.vars[name];
74
+ if (next !== undefined) {
75
+ root.style.setProperty(name, String(next));
76
+ }
77
+ else if (base.value) {
78
+ // Value AND priority: a host token declared `!important` keeps its
79
+ // flag, or anything else can start overriding a theme it owns.
80
+ root.style.setProperty(name, base.value, base.priority);
81
+ }
82
+ else {
83
+ root.style.removeProperty(name);
84
+ rootThemeWritten.delete(name);
85
+ continue;
86
+ }
87
+ rememberWrite(root, name);
88
+ }
89
+ }
90
+ /**
91
+ * Publish `vars` on the document for `id`, claiming a place in the stack the
92
+ * first time and KEEPING it afterwards.
93
+ *
94
+ * Keeping it is the point: a scope whose theme merely changes — a rerender
95
+ * that hands back a new `variables` object, even a semantically identical
96
+ * one from an inline `layers` array — must not jump over a scope that
97
+ * mounted after it. Re-registering on every dependency change would make
98
+ * the newest write win instead of the newest owner (review catch).
99
+ */
100
+ function writeRootTheme(root, id, vars) {
101
+ for (const name of Object.keys(vars)) {
102
+ if (!rootThemeBase.has(name)) {
103
+ rootThemeBase.set(name, {
104
+ value: root.style.getPropertyValue(name),
105
+ priority: root.style.getPropertyPriority(name),
106
+ });
107
+ }
108
+ }
109
+ const existing = rootThemeStack.find((write) => write.id === id);
110
+ if (existing)
111
+ existing.vars = vars;
112
+ else
113
+ rootThemeStack.push({ id, vars });
114
+ applyTopRootTheme(root);
115
+ }
116
+ /** Give up `id`'s place, wherever in the stack it sits. */
117
+ function releaseRootTheme(root, id) {
118
+ const at = rootThemeStack.findIndex((write) => write.id === id);
119
+ if (at >= 0)
120
+ rootThemeStack.splice(at, 1);
121
+ applyTopRootTheme(root);
122
+ if (rootThemeStack.length === 0) {
123
+ rootThemeBase.clear();
124
+ rootThemeWritten.clear();
125
+ }
126
+ }
36
127
  export function ThemeScope({ children, className, style, }) {
37
128
  const runtime = useThemeRuntime();
38
- return (_jsx("div", { className: className, style: { ...runtime.variables, fontFamily: "var(--font-body)", ...style }, children: children }));
129
+ const nested = useContext(ThemeScopeNestedContext);
130
+ // Variables on this div alone reach only what renders INSIDE it. Radix
131
+ // portals mount into document.body, outside it, so every dropdown,
132
+ // popover, select and tooltip fell back to :root — the base preset —
133
+ // while the page around them wore the customer's theme. `dialog` papered
134
+ // over its own case with a nested ThemeScope; that fixes one portal and
135
+ // leaves the next one to rediscover the bug.
136
+ //
137
+ // The outermost scope publishes to the document element instead, so
138
+ // anything portalled anywhere inherits. Nested scopes keep the div only:
139
+ // they exist to theme a subtree differently, and must not overwrite the
140
+ // document with their own values.
141
+ //
142
+ // Residual, stated because it is a real limit and not an oversight: a
143
+ // NESTED scope's own portalled content still resolves the document's
144
+ // (outermost) theme, because the portal escapes the nested div and the
145
+ // nested scope deliberately does not publish. That is strictly better
146
+ // than before — such content used to get the base `:root` preset rather
147
+ // than any theme at all — and the remedy already exists and is used:
148
+ // wrap the portalled content in a `ThemeScope`, as `ui/dialog.tsx` does
149
+ // with `display: contents`. Context reaches through a portal, so the
150
+ // nested scope's variables land on the portalled subtree (review catch).
151
+ // BEFORE paint, not after. A portal that exists on the very first render —
152
+ // a default-open dialog, an overlay restored during hydration — would
153
+ // otherwise paint once against the base `:root` preset and then correct
154
+ // itself: the mismatched-theme flash this exists to remove, in miniature
155
+ // (review catch).
156
+ // The identity of THIS scope, stable for its whole life. Its place in the
157
+ // stack is claimed once and released once; changing its theme in between
158
+ // must not re-order it (review catch).
159
+ const idRef = useRef(undefined);
160
+ idRef.current ?? (idRef.current = Symbol("theme-scope"));
161
+ useLayoutEffect(() => {
162
+ if (nested || typeof document === "undefined")
163
+ return;
164
+ const root = document.documentElement;
165
+ const id = idRef.current;
166
+ return () => releaseRootTheme(root, id);
167
+ }, [nested]);
168
+ useLayoutEffect(() => {
169
+ if (nested || typeof document === "undefined")
170
+ return;
171
+ // `font-family` rides along as one more name: `<html>` needs it set,
172
+ // not just `--font-body` defined, or portalled content keeps the host's
173
+ // font (review catch).
174
+ const vars = {
175
+ ...runtime.variables,
176
+ "font-family": "var(--font-body)",
177
+ };
178
+ writeRootTheme(document.documentElement, idRef.current, vars);
179
+ }, [nested, runtime.variables]);
180
+ return (_jsx(ThemeScopeNestedContext.Provider, { value: true, children: _jsx("div", { className: className, style: { ...runtime.variables, fontFamily: "var(--font-body)", ...style }, children: children }) }));
39
181
  }
40
182
  export function ThemeDocumentMetadata() {
41
183
  const runtime = useThemeRuntime();
package/dist/styles.css CHANGED
@@ -184,6 +184,27 @@
184
184
  box-shadow: 6px 0 6px -6px color-mix(in srgb, var(--foreground) 12%, transparent);
185
185
  }
186
186
 
187
+ /* The opposite call from the list scroller below, for the opposite reason.
188
+ * The toolbar's selection row shares a line with the search and the actions,
189
+ * and its entire contract is that ticking a row never changes the toolbar's
190
+ * height. A classic, space-consuming scrollbar — Windows and Linux by
191
+ * default, macOS set to "always show" — would appear exactly when the bar
192
+ * overflows, which is exactly when something is ticked, and its height would
193
+ * land on the toolbar as the same jump by another route (review catch).
194
+ *
195
+ * So the widget is hidden, not the scrolling: wheel, trackpad, and tabbing
196
+ * to a button past the edge all still reach it. Plain CSS rather than
197
+ * utilities, because a class that a consumer's Tailwind never scans fails
198
+ * silently, and this one has no visible symptom until the platform is one we
199
+ * do not develop on. */
200
+ [data-selection-row] {
201
+ scrollbar-width: none;
202
+ }
203
+ [data-selection-row]::-webkit-scrollbar {
204
+ height: 0;
205
+ display: none;
206
+ }
207
+
187
208
  /* A list you can scroll should say so: keep its scrollbars visible rather
188
209
  * than relying on the overlay ones macOS hides until you already scroll. */
189
210
  [data-list-scroller] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
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.21.0",
75
- "@iloveagents/foundry-web-primitives": "^0.21.0"
74
+ "@iloveagents/foundry-agent": "^0.21.1",
75
+ "@iloveagents/foundry-web-primitives": "^0.21.1"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@ag-ui/client": "^0.0.52",