@ciridae/ui 0.9.0 → 0.10.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.
@@ -0,0 +1,19 @@
1
+ import type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
2
+ import type { McpAccessToolSelector } from "./mcp-access-types";
3
+ /**
4
+ * The tool picker for inventories big enough to need navigation: one scroll
5
+ * region grouped by each tool's first OpenAPI tag, each group split into Read
6
+ * and Actions with a select-all per split, and a filter box on top. An
7
+ * inventory with no tags renders as a single unlabeled group, so the split,
8
+ * the select-alls, and the filter still apply.
9
+ *
10
+ * Every change is rebuilt from the full inventory against the one shared
11
+ * selection, so a tool selected but hidden by the filter is never dropped —
12
+ * only the rows on screen, and the select-alls over them, can change it.
13
+ */
14
+ export declare function McpAccessGroupedToolPicker({ busy, inventory, selected, onChange, }: {
15
+ busy: boolean;
16
+ inventory: McpToolInventoryState;
17
+ selected: McpAccessToolSelector[];
18
+ onChange: (tools: McpAccessToolSelector[]) => void;
19
+ }): import("@types/react").JSX.Element;
@@ -0,0 +1,116 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Checkbox } from "../ui/checkbox";
4
+ import { Input } from "../ui/input";
5
+ import { Spinner } from "../ui/spinner";
6
+ import { EmptySelectionWarning, InventoryUnavailableNote, keyOf, PickerRow, } from "./mcp-access-tool-picker-internals";
7
+ const UNTAGGED_LABEL = "Other";
8
+ /**
9
+ * The tool picker for inventories big enough to need navigation: one scroll
10
+ * region grouped by each tool's first OpenAPI tag, each group split into Read
11
+ * and Actions with a select-all per split, and a filter box on top. An
12
+ * inventory with no tags renders as a single unlabeled group, so the split,
13
+ * the select-alls, and the filter still apply.
14
+ *
15
+ * Every change is rebuilt from the full inventory against the one shared
16
+ * selection, so a tool selected but hidden by the filter is never dropped —
17
+ * only the rows on screen, and the select-alls over them, can change it.
18
+ */
19
+ export function McpAccessGroupedToolPicker({ busy, inventory, selected, onChange, }) {
20
+ const [query, setQuery] = useState("");
21
+ if (inventory.data === undefined) {
22
+ return _jsx(InventoryUnavailableNote, { isPending: inventory.isPending });
23
+ }
24
+ const tools = inventory.data.tools;
25
+ const selectedKeys = new Set(selected.map((tool) => keyOf(tool)));
26
+ const applyKeys = (keys, checked) => {
27
+ const next = new Set(selectedKeys);
28
+ for (const key of keys) {
29
+ if (checked)
30
+ next.add(key);
31
+ else
32
+ next.delete(key);
33
+ }
34
+ onChange(tools
35
+ .filter((tool) => next.has(keyOf(tool)))
36
+ .map((tool) => ({ method: tool.method, pathTemplate: tool.pathTemplate })));
37
+ };
38
+ const needle = query.trim().toLowerCase();
39
+ const groups = groupByFirstTag(tools);
40
+ const visibleGroups = groups
41
+ .map((group) => ({ ...group, visible: group.tools.filter((tool) => matches(tool, needle)) }))
42
+ .filter((group) => group.visible.length > 0);
43
+ const visibleCount = visibleGroups.reduce((count, group) => count + group.visible.length, 0);
44
+ const chosen = tools.filter((tool) => selectedKeys.has(keyOf(tool)));
45
+ return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsx(Input, { "aria-label": "Filter tools", className: "h-8 text-xs", onChange: (event) => {
46
+ setQuery(event.target.value);
47
+ }, placeholder: `Filter ${tools.length} tools…`, type: "search", value: query }), _jsx("div", { className: "max-h-96 overflow-y-auto rounded-md border border-border/60", children: visibleGroups.length === 0 ? (_jsx("p", { className: "text-muted-foreground px-3 py-2 text-xs", children: tools.length === 0 ? "This API exposes no tools." : "No tools match the filter." })) : (visibleGroups.map((group) => (_jsx(GroupSection, { busy: busy, group: group, onApply: applyKeys, selectedKeys: selectedKeys }, group.label ?? "")))) }), _jsx(PickerFooter, { busy: busy, chosen: chosen.length, matching: needle === "" ? null : visibleCount, total: tools.length })] }));
48
+ }
49
+ function PickerFooter({ busy, chosen, matching, total, }) {
50
+ return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [busy ? _jsx(Spinner, { "aria-label": "Saving selection", className: "text-muted-foreground" }) : null, _jsxs("p", { "aria-live": "polite", className: "text-muted-foreground text-xs", children: [chosen, " of ", total, " tools selected"] }), matching === null ? null : (_jsxs("p", { className: "text-muted-foreground text-xs", children: [matching, " match", matching === 1 ? "es" : "", " the filter"] })), chosen === 0 ? _jsx(EmptySelectionWarning, {}) : null] }));
51
+ }
52
+ function matches(tool, needle) {
53
+ if (needle === "")
54
+ return true;
55
+ return [tool.name, tool.description, `${tool.method} ${tool.pathTemplate}`].some((text) => text.toLowerCase().includes(needle));
56
+ }
57
+ /** One tag group: its sticky header with counts, then the Read and Actions runs. */
58
+ function GroupSection({ busy, group, selectedKeys, onApply, }) {
59
+ const chosenInGroup = group.tools.filter((tool) => selectedKeys.has(keyOf(tool))).length;
60
+ return (_jsxs("section", { children: [group.label === null ? null : (_jsxs("h4", { className: "bg-background sticky top-0 z-10 flex items-baseline justify-between gap-2 border-b border-border/60 px-3 py-1.5 font-mono text-xs font-semibold", children: [_jsx("span", { className: "truncate", children: group.label }), _jsxs("span", { className: "text-muted-foreground shrink-0 text-[10px] font-normal tabular-nums", children: [chosenInGroup, " / ", group.tools.length] })] })), [
61
+ { label: "Read", rows: group.visible.filter((tool) => tool.readOnly) },
62
+ { label: "Actions", rows: group.visible.filter((tool) => !tool.readOnly) },
63
+ ].map(({ label, rows }) => rows.length === 0 ? null : (_jsx(SubsectionRows, { busy: busy, groupLabel: group.label, label: label, onApply: onApply, rows: rows, selectedKeys: selectedKeys }, label)))] }));
64
+ }
65
+ /** One Read or Actions run within a group: its header with select-all, then rows. */
66
+ function SubsectionRows({ busy, groupLabel, label, rows, selectedKeys, onApply, }) {
67
+ const rowKeys = rows.map((tool) => keyOf(tool));
68
+ const picked = rowKeys.filter((key) => selectedKeys.has(key)).length;
69
+ const allState = picked === 0 ? false : picked === rowKeys.length ? true : "indeterminate";
70
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 pt-2 pb-1", children: [_jsx("span", { className: label === "Actions"
71
+ ? "text-primary text-[10px] font-semibold tracking-wide uppercase"
72
+ : "text-muted-foreground text-[10px] font-semibold tracking-wide uppercase", children: label }), _jsx(Checkbox, { "aria-label": `Select all ${label.toLowerCase()}${groupLabel === null ? "" : ` in ${groupLabel}`}`, checked: allState, disabled: busy, onCheckedChange: (next) => {
73
+ // The rows on screen only: with a filter active this is
74
+ // "select what I searched for", never the hidden rest.
75
+ onApply(rowKeys, next === true);
76
+ } })] }), _jsx("ul", { className: "divide-y divide-border/50", children: rows.map((tool) => (_jsx(PickerRow, { checked: selectedKeys.has(keyOf(tool)), disabled: busy,
77
+ // The subheader above already says Read or Actions.
78
+ kindBadge: false, onToggle: (key, checked) => {
79
+ onApply([key], checked);
80
+ }, tool: tool }, keyOf(tool)))) })] }));
81
+ }
82
+ /**
83
+ * Buckets by first tag in first-appearance order — a compiler emits tools in
84
+ * source order, so tag order is the API's own organization. Untagged tools
85
+ * trail as one "Other" bucket, unless nothing is tagged at all: then the one
86
+ * group is unlabeled rather than titled "Other" for no contrast.
87
+ */
88
+ function groupByFirstTag(tools) {
89
+ const buckets = new Map();
90
+ for (const tool of tools) {
91
+ const firstTag = tool.tags?.[0];
92
+ if (firstTag === undefined)
93
+ continue;
94
+ const bucket = buckets.get(firstTag);
95
+ if (bucket)
96
+ bucket.push(tool);
97
+ else
98
+ buckets.set(firstTag, [tool]);
99
+ }
100
+ if (buckets.size === 0)
101
+ return [{ label: null, tools }];
102
+ const untagged = tools.filter((tool) => (tool.tags?.length ?? 0) === 0);
103
+ if (untagged.length > 0) {
104
+ // Into the real "Other" tag's bucket when one exists, so two groups
105
+ // never share a label (and with it a React key).
106
+ const bucket = buckets.get(UNTAGGED_LABEL);
107
+ if (bucket)
108
+ bucket.push(...untagged);
109
+ else
110
+ buckets.set(UNTAGGED_LABEL, untagged);
111
+ }
112
+ return [...buckets.entries()].map(([label, bucketTools]) => ({
113
+ label,
114
+ tools: bucketTools,
115
+ }));
116
+ }
@@ -0,0 +1,35 @@
1
+ import type { McpAccessToolListing } from "./mcp-access-types";
2
+ /** The coordinate a selector and a compiled operation are matched on. */
3
+ export declare const keyOf: (tool: {
4
+ method: string;
5
+ pathTemplate: string;
6
+ }) => string;
7
+ /**
8
+ * The slice of the app's inventory query the pickers read, shaped so a
9
+ * TanStack Query result can be passed whole. `data` is `undefined` for both
10
+ * "still loading" and "failed to load"; `isPending` says which.
11
+ */
12
+ export type McpToolInventoryState = {
13
+ data: {
14
+ tools: McpAccessToolListing[];
15
+ } | undefined;
16
+ isPending: boolean;
17
+ };
18
+ /** The one line an absent inventory renders, in both pickers. */
19
+ export declare function InventoryUnavailableNote({ isPending }: {
20
+ isPending: boolean;
21
+ }): import("@types/react").JSX.Element;
22
+ /** Why an empty selection blocks the mint, in both pickers' footers. */
23
+ export declare function EmptySelectionWarning(): import("@types/react").JSX.Element;
24
+ /**
25
+ * One selectable tool row. `kindBadge` shows the read-only/action label — the
26
+ * flat list's only signal, redundant under the grouped picker's Read/Actions
27
+ * subheaders.
28
+ */
29
+ export declare function PickerRow({ checked, disabled, kindBadge, tool, onToggle, }: {
30
+ checked: boolean;
31
+ disabled: boolean;
32
+ kindBadge: boolean;
33
+ tool: McpAccessToolListing;
34
+ onToggle: (key: string, checked: boolean) => void;
35
+ }): import("@types/react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from "react";
3
+ import { Checkbox } from "../ui/checkbox";
4
+ /** The coordinate a selector and a compiled operation are matched on. */
5
+ export const keyOf = (tool) => `${tool.method} ${tool.pathTemplate}`;
6
+ /** The one line an absent inventory renders, in both pickers. */
7
+ export function InventoryUnavailableNote({ isPending }) {
8
+ return (_jsx("p", { className: "text-muted-foreground text-xs", children: isPending ? "Loading the tool list…" : "Couldn't load the tool list." }));
9
+ }
10
+ /** Why an empty selection blocks the mint, in both pickers' footers. */
11
+ export function EmptySelectionWarning() {
12
+ return (_jsx("p", { className: "text-destructive text-xs", children: "Select at least one tool to generate a connection code." }));
13
+ }
14
+ /**
15
+ * One selectable tool row. `kindBadge` shows the read-only/action label — the
16
+ * flat list's only signal, redundant under the grouped picker's Read/Actions
17
+ * subheaders.
18
+ */
19
+ export function PickerRow({ checked, disabled, kindBadge, tool, onToggle, }) {
20
+ const id = useId();
21
+ return (_jsxs("li", { className: "flex min-h-11 items-start gap-3 px-3 py-2", children: [_jsx(Checkbox, { checked: checked, disabled: disabled, id: id, onCheckedChange: (next) => {
22
+ onToggle(keyOf(tool), next === true);
23
+ } }), _jsxs("label", { className: "min-w-0 flex-1 cursor-pointer", htmlFor: id, children: [kindBadge ? (_jsxs("span", { className: "flex flex-wrap items-center gap-x-2", children: [_jsx("span", { className: "truncate font-mono text-xs", children: tool.name }), _jsx("span", { className: "text-muted-foreground text-[10px] uppercase", children: tool.readOnly ? "read-only" : "action" })] })) : (_jsx("span", { className: "block truncate font-mono text-xs", children: tool.name })), _jsx("span", { className: "text-muted-foreground block truncate text-xs", children: tool.description })] })] }));
24
+ }
@@ -1,20 +1,7 @@
1
- import type { McpAccessToolListing, McpAccessToolSelector } from "./mcp-access-types";
2
- /** The coordinate a selector and a compiled operation are matched on. */
3
- export declare const keyOf: (tool: {
4
- method: string;
5
- pathTemplate: string;
6
- }) => string;
7
- /**
8
- * The slice of the app's inventory query the picker reads, shaped so a
9
- * TanStack Query result can be passed whole. `data` is `undefined` for both
10
- * "still loading" and "failed to load"; `isPending` says which.
11
- */
12
- export type McpToolInventoryState = {
13
- data: {
14
- tools: McpAccessToolListing[];
15
- } | undefined;
16
- isPending: boolean;
17
- };
1
+ import type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
2
+ import type { McpAccessToolSelector } from "./mcp-access-types";
3
+ export { keyOf } from "./mcp-access-tool-picker-internals";
4
+ export type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
18
5
  /**
19
6
  * An exact tool selection, applied as it is clicked. There is nothing to
20
7
  * confirm: the list on screen is always what a code will be minted with, and an
@@ -1,9 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId } from "react";
3
- import { Checkbox } from "../ui/checkbox";
4
2
  import { Spinner } from "../ui/spinner";
5
- /** The coordinate a selector and a compiled operation are matched on. */
6
- export const keyOf = (tool) => `${tool.method} ${tool.pathTemplate}`;
3
+ import { EmptySelectionWarning, InventoryUnavailableNote, keyOf, PickerRow, } from "./mcp-access-tool-picker-internals";
4
+ export { keyOf } from "./mcp-access-tool-picker-internals";
7
5
  /**
8
6
  * An exact tool selection, applied as it is clicked. There is nothing to
9
7
  * confirm: the list on screen is always what a code will be minted with, and an
@@ -17,7 +15,7 @@ export const keyOf = (tool) => `${tool.method} ${tool.pathTemplate}`;
17
15
  export function McpAccessToolPicker({ busy, inventory, selected, onChange, }) {
18
16
  const selectedKeys = new Set(selected.map((tool) => keyOf(tool)));
19
17
  if (inventory.data === undefined) {
20
- return (_jsx("p", { className: "text-muted-foreground text-xs", children: inventory.isPending ? "Loading the tool list…" : "Couldn't load the tool list." }));
18
+ return _jsx(InventoryUnavailableNote, { isPending: inventory.isPending });
21
19
  }
22
20
  const tools = inventory.data.tools;
23
21
  const toggle = (key, checked) => {
@@ -31,11 +29,5 @@ export function McpAccessToolPicker({ busy, inventory, selected, onChange, }) {
31
29
  .map((tool) => ({ method: tool.method, pathTemplate: tool.pathTemplate })));
32
30
  };
33
31
  const chosen = tools.filter((tool) => selectedKeys.has(keyOf(tool)));
34
- return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsx("ul", { className: "max-h-64 divide-y divide-border/50 overflow-y-auto rounded-md border border-border/60", children: tools.map((tool) => (_jsx(PickerRow, { checked: selectedKeys.has(keyOf(tool)), disabled: busy, onToggle: toggle, tool: tool }, keyOf(tool)))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [busy ? _jsx(Spinner, { "aria-label": "Saving selection", className: "text-muted-foreground" }) : null, _jsxs("p", { "aria-live": "polite", className: "text-muted-foreground text-xs", children: [chosen.length, " of ", tools.length, " tools selected"] }), chosen.length === 0 ? (_jsx("p", { className: "text-destructive text-xs", children: "Select at least one tool to generate a connection code." })) : null] })] }));
35
- }
36
- function PickerRow({ checked, disabled, tool, onToggle, }) {
37
- const id = useId();
38
- return (_jsxs("li", { className: "flex min-h-11 items-start gap-3 px-3 py-2", children: [_jsx(Checkbox, { checked: checked, disabled: disabled, id: id, onCheckedChange: (next) => {
39
- onToggle(keyOf(tool), next === true);
40
- } }), _jsxs("label", { className: "min-w-0 flex-1 cursor-pointer", htmlFor: id, children: [_jsxs("span", { className: "flex flex-wrap items-center gap-x-2", children: [_jsx("span", { className: "truncate font-mono text-xs", children: tool.name }), _jsx("span", { className: "text-muted-foreground text-[10px] uppercase", children: tool.readOnly ? "read-only" : "action" })] }), _jsx("span", { className: "block truncate text-muted-foreground text-xs", children: tool.description })] })] }));
32
+ return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsx("ul", { className: "max-h-64 divide-y divide-border/50 overflow-y-auto rounded-md border border-border/60", children: tools.map((tool) => (_jsx(PickerRow, { checked: selectedKeys.has(keyOf(tool)), disabled: busy, kindBadge: true, onToggle: toggle, tool: tool }, keyOf(tool)))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [busy ? _jsx(Spinner, { "aria-label": "Saving selection", className: "text-muted-foreground" }) : null, _jsxs("p", { "aria-live": "polite", className: "text-muted-foreground text-xs", children: [chosen.length, " of ", tools.length, " tools selected"] }), chosen.length === 0 ? _jsx(EmptySelectionWarning, {}) : null] })] }));
41
33
  }
@@ -21,6 +21,8 @@ export type McpAccessToolListing = {
21
21
  readOnly: boolean;
22
22
  method: McpHttpMethod;
23
23
  pathTemplate: string;
24
+ /** The operation's OpenAPI tags; the grouped picker buckets by the first. */
25
+ tags?: readonly string[];
24
26
  };
25
27
  /**
26
28
  * What a mint produced, as the fields these controls report: the access the
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { CheckIcon } from "lucide-react";
3
+ import { CheckIcon, MinusIcon } from "lucide-react";
4
4
  import { Checkbox as CheckboxPrimitive } from "radix-ui";
5
5
  import { cn } from "../../lib/utils";
6
6
  function Checkbox({ className, ...props }) {
7
- return (_jsx(CheckboxPrimitive.Root, { "data-slot": "checkbox", className: cn("peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary", className), ...props, children: _jsx(CheckboxPrimitive.Indicator, { "data-slot": "checkbox-indicator", className: "grid place-content-center text-current transition-none", children: _jsx(CheckIcon, { className: "size-3.5" }) }) }));
7
+ return (_jsx(CheckboxPrimitive.Root, { "data-slot": "checkbox", className: cn("peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary", className), ...props, children: _jsx(CheckboxPrimitive.Indicator, { "data-slot": "checkbox-indicator", className: "grid place-content-center text-current transition-none", children: props.checked === "indeterminate" ? (_jsx(MinusIcon, { className: "size-3.5" })) : (_jsx(CheckIcon, { className: "size-3.5" })) }) }));
8
8
  }
9
9
  export { Checkbox };
@@ -0,0 +1 @@
1
+ export * from "../components/blocks/mcp-access-grouped-tool-picker";
@@ -0,0 +1 @@
1
+ export * from "../components/blocks/mcp-access-grouped-tool-picker";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciridae/ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "engines": {