@hachej/boring-data-explorer 0.1.40 → 0.1.42

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.
package/README.md CHANGED
@@ -1,271 +1,127 @@
1
1
  # @hachej/boring-data-explorer
2
2
 
3
- <div align="center">
3
+ Searchable, faceted data tables for the workbench. This is the headless
4
+ primitive that `@hachej/boring-data-catalog` and other explorer-style plugins
5
+ build on. It is **not** a workspace plugin itself — it ships a React component,
6
+ a hook, and an adapter contract.
4
7
 
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ ## What it does
6
9
 
7
- </div>
10
+ - Renders a `<DataExplorer>` table: search box, faceted filter popover, optional
11
+ group/tree mode, row activation, and drag-out payloads.
12
+ - Manages query / facet / paging / selection state via `useExplorerState`.
13
+ - Defines the `ExplorerDataSource` adapter contract: you implement
14
+ `search(args)` (and optionally `fetchFacets(args)`) against any backend.
8
15
 
9
- Searchable, faceted data tables for the workbench. The headless primitive that `data-catalog` and other explorer-style plugins build on top of.
16
+ ## Usage
10
17
 
11
- ```bash
12
- git clone https://github.com/hachej/boring-ui.git && cd boring-ui && pnpm install
13
- ```
14
-
15
- > **Note:** This plugin is workspace-private (`"private": true`) — install from source within the monorepo.
16
-
17
- ---
18
-
19
- ## TL;DR
20
-
21
- **The Problem**: You have data — customers, invoices, logs, metrics — and you want users to search, filter, and explore it inside an agent app. But building faceted tables with virtualization, paging, and selection from scratch is tedious.
22
-
23
- **The Solution**: `@hachej/boring-data-explorer` provides a controlled `<DataExplorer>` component + `useExplorerState` hook + an `ExplorerDataSource` adapter contract. You implement `search(args)` (and optionally `fetchFacets(args)`) against any backend. The component handles the rest.
24
-
25
- ### Why Use @hachej/boring-data-explorer?
26
-
27
- | Feature | What It Does |
28
- |---------|--------------|
29
- | **`<DataExplorer>` component** | Search box, faceted filters, virtualized rows, row selection, drag-and-drop payload |
30
- | **`useExplorerState` hook** | Manages query, facets, paging, and selection state |
31
- | **Adapter contract** | `search(args)` + optional `fetchFacets(args)` — implement once, plug any backend (SQL, REST, in-memory, gRPC) |
32
- | **Agent-driven** | Rows expose a `DragPayload`; the agent can open any row via surface resolver |
33
- | **Headless + styled** | Use the hook alone for custom UI, or mount the full component out of the box |
34
-
35
- ---
36
-
37
- ## Quick Example
18
+ `<DataExplorer>` is self-contained — it calls `useExplorerState` internally, so
19
+ you pass the **adapter** (not a state object):
38
20
 
39
21
  ```tsx
40
- import { DataExplorer, useExplorerState } from "@hachej/boring-data-explorer/front"
41
- import type { ExplorerDataSource, SearchArgs, SearchResult } from "@hachej/boring-data-explorer/shared"
22
+ import { DataExplorer } from "@hachej/boring-data-explorer/front"
23
+ import type { ExplorerDataSource } from "@hachej/boring-data-explorer/shared"
42
24
 
43
- // 1. Implement the adapter against your backend
44
- const customersSource: ExplorerDataSource = {
45
- async search({ query, filters, limit, offset, signal }): Promise<SearchResult> {
25
+ const customers: ExplorerDataSource = {
26
+ async search({ query, filters, limit, offset, signal }) {
46
27
  const res = await fetch(`/api/customers?q=${query}&limit=${limit}&offset=${offset}`, { signal })
47
28
  const { items, total } = await res.json()
48
29
  return { items, total, hasMore: offset + items.length < total }
49
30
  },
50
31
  }
51
32
 
52
- // 2. Mount the explorer
53
33
  export function CustomersPane() {
54
- const state = useExplorerState({ source: customersSource, pageSize: 50 })
55
- return <DataExplorer state={state} />
34
+ return <DataExplorer adapter={customers} pageSize={50} />
56
35
  }
57
36
  ```
58
37
 
59
- The result is a sortable, filterable table — users can search, filter by facets, select rows, and drag rows into other panels.
38
+ Use `useExplorerState({ adapter, facets, groupBy, pageSize })` directly only
39
+ when you need to drive a fully custom UI.
40
+
41
+ ### `<DataExplorer>` props
60
42
 
61
- ---
43
+ `adapter` (required), `facets`, `groupBy`, `onActivate(row)`,
44
+ `getDragPayload(row)`, `emptyState`, `searchPlaceholder`, `toolbarTitle`,
45
+ `toolbarIcon`, `searchable`, `query` + `onQueryChange` (controlled search),
46
+ `pageSize`, `debounceMs`, `className`. Facets render only when the adapter
47
+ implements `fetchFacets`. `groupBy` enables tree mode (the key must match a
48
+ facet key); an active query/filter forces flat mode.
62
49
 
63
- ## Adapter Contract
50
+ ## Adapter contract
64
51
 
65
52
  ```ts
66
- // Explorer item shape rendered by the DataExplorer
67
- export type ExplorerItem = {
53
+ type ExplorerItem = {
68
54
  id: string
69
55
  title: string
70
- subtitle?: string // muted second line (truncates with title)
71
- group?: string // group key — matches a facet value for grouping
72
- leading?: Badge // leading mono chip (e.g. type code)
73
- trailing?: Badge[] // trailing chips for status flags
74
- meta?: string // right-aligned plain text (e.g. "1.2M")
56
+ subtitle?: string // muted second line
57
+ group?: string // group key — must match a facet value
58
+ leading?: Badge // leading mono chip
59
+ trailing?: Badge[] // trailing status chips
60
+ meta?: string // right-aligned plain text (e.g. "1.2M")
75
61
  }
62
+ type Badge = { code: string; tooltip?: string } // code = 1–4 char chip
76
63
 
77
- export type Badge = {
78
- code: string // 1-4 char mono code rendered as a chip
79
- tooltip?: string
64
+ type FacetConfig = {
65
+ key: string
66
+ label: string
67
+ order?: string[] // explicit display order
68
+ formatValue?: (value: string) => string
80
69
  }
81
70
 
82
- // Search arguments from the explorer to your adapter
83
- export type SearchArgs = {
71
+ type SearchArgs = {
84
72
  query: string
85
- filters: Record<string, string[]> // active facet filters
86
- group?: { key: string; value: string } // scope to single group (paging inside a group)
73
+ filters: Record<string, string[]>
74
+ group?: { key: string; value: string } // set when paging inside a group
87
75
  limit: number
88
76
  offset: number
89
77
  signal?: AbortSignal
90
78
  }
79
+ type SearchResult = { items: ExplorerItem[]; total: number; hasMore: boolean }
91
80
 
92
- // Search result your adapter returns
93
- export type SearchResult = {
94
- items: ExplorerItem[]
95
- total: number // total count for the current scope (query + filters + optional group)
96
- hasMore: boolean // whether there are more pages
97
- }
98
-
99
- // Facets — optional. When omitted, the explorer renders flat (no facet popover)
100
- export type Facets = Record<string, FacetValue[]>
101
- export type FacetValue = { value: string; count: number }
81
+ type FacetsArgs = { filters: Record<string, string[]>; signal?: AbortSignal }
82
+ type Facets = Record<string, { value: string; count: number }[]>
102
83
 
103
- export type FacetsArgs = {
104
- filters: Record<string, string[]>
105
- signal?: AbortSignal
106
- }
107
-
108
- // The adapter contract
109
- export interface ExplorerDataSource {
84
+ interface ExplorerDataSource {
110
85
  search(args: SearchArgs): Promise<SearchResult>
111
86
  fetchFacets?(args: FacetsArgs): Promise<Facets> // optional
112
87
  }
113
- ```
114
-
115
- Implement against **any backend**. The component doesn't care whether data comes from SQL, REST, Elasticsearch, or a JSON file.
116
-
117
- ### Static Data Adapter
118
88
 
119
- ```tsx
120
- import type { ExplorerDataSource, ExplorerItem } from "@hachej/boring-data-explorer/shared"
121
-
122
- const entries: ExplorerItem[] = [
123
- { id: "customers", title: "Customers", subtitle: "Customer records", leading: { code: "tbl" } },
124
- { id: "invoices", title: "Invoices", subtitle: "Invoice records", leading: { code: "tbl" }, group: "finance" },
125
- ]
126
-
127
- const adapter: ExplorerDataSource = {
128
- async search({ query, limit, offset }) {
129
- const normalized = query.trim().toLowerCase()
130
- const matched = normalized
131
- ? entries.filter((entry) => `${entry.title} ${entry.subtitle ?? ""}`.toLowerCase().includes(normalized))
132
- : entries
133
- const items = matched.slice(offset, offset + limit)
134
- return { items, total: matched.length, hasMore: offset + items.length < matched.length }
135
- },
136
- }
89
+ type DragPayload = { mimeType: string; value: string }
137
90
  ```
138
91
 
139
- ---
92
+ The adapter is backend-agnostic — SQL, REST, in-memory, anything that can return
93
+ `ExplorerItem[]`. A static in-memory adapter just filters and slices an array
94
+ inside `search()`.
140
95
 
141
- ## Installation
96
+ ## Package surfaces
142
97
 
143
- ```bash
144
- # From source (workspace-only — not published to npm)
145
- cd boring-ui/plugins/data-explorer && pnpm install && pnpm build
146
- ```
98
+ | Import | Exports |
99
+ |--------|---------|
100
+ | `@hachej/boring-data-explorer` / `/front` | `DataExplorer`, `useExplorerState`, all types |
101
+ | `@hachej/boring-data-explorer/shared` | contract types only (no React) |
102
+ | `@hachej/boring-data-explorer/testing` | `createMockSeriesAdapter`, `createMockTablesAdapter` |
147
103
 
148
- ---
104
+ ## Notes
149
105
 
150
- ## Package Surfaces
151
-
152
- | Import | Environment | What You Get |
153
- |--------|-------------|--------------|
154
- | `@hachej/boring-data-explorer` | Browser | `<DataExplorer>`, `useExplorerState`, all types re-exported |
155
- | `@hachej/boring-data-explorer/front` | Browser | Same as top-level (explicit subpath) |
156
- | `@hachej/boring-data-explorer/shared` | Any | `ExplorerDataSource`, `ExplorerItem`, `Facets`, `SearchArgs`, `DragPayload`, `Badge` — no React deps |
157
- | `@hachej/boring-data-explorer/testing` | Browser | Test utilities and mock data sources |
158
-
159
- ---
160
-
161
- ## Architecture
162
-
163
- ```
164
- ┌─────────────────────────────┐
165
- │ <DataExplorer> │
166
- │ ┌───┬─────┬─────────────┐ │
167
- │ │ 🔍 │ Facets │ Results │ │
168
- │ └───┴─────┴─────────────┘ │
169
- │ ┌───┐ ┌───────────────┐ │
170
- │ │ ← │ │ 1..25 of 430 │ │
171
- │ └───┘ └───────────────┘ │
172
- └──────────────┬─────────────┘
173
- │ source.search(args)
174
- │ source.fetchFacets?(args)
175
- ┌──────────────▼─────────────┐
176
- │ ExplorerDataSource │
177
- │ (your adapter impl) │
178
- │ │
179
- │ search({ query, filters, │
180
- │ limit, offset }) │
181
- │ │
182
- │ fetchFacets?({ filters }) │
183
- └──────────────┬─────────────┘
184
-
185
- ┌──────────────▼─────────────┐
186
- │ Any Backend │
187
- │ PostgreSQL · REST API │
188
- │ Elasticsearch · In-memory│
189
- │ gRPC · whatever │
190
- └────────────────────────────┘
191
- ```
192
-
193
- ---
194
-
195
- ## How @hachej/boring-data-explorer Compares
196
-
197
- | Feature | @hachej/boring-data-explorer | AG Grid | TanStack Table | Build your own |
198
- |---------|------------------------------|---------|----------------|----------------|
199
- | Built-in search + facets | ✅ One hook | ⚠️ Filter only | ❌ DIY | ❌ |
200
- | Backend adapter contract | ✅ `search` + `fetchFacets` | ❌ In-memory | ⚠️ Sorting/filtering | ❌ |
201
- | Drag-and-drop payload | ✅ `DragPayload` type | ⚠️ Add-on | ❌ | ❌ |
202
- | Agent integration | ✅ Surface resolver | ❌ | ❌ | ❌ |
203
- | Virtualized rows | ✅ | ✅ | ⚠️ Manual | ❌ |
204
- | Complexity | ✅ ~10 lines to mount | ❌ Heavy API | ⚠️ Complex API | ❌ Weeks |
205
-
206
- **When to use @hachej/boring-data-explorer:**
207
- - You want a fast, searchable table with facet filters in a boring-ui workbench
208
- - You're building a data catalog plugin for an agent app
209
- - You need drag-and-drop from table rows to other panels
210
-
211
- **When it might not fit:**
212
- - You need enterprise-grade data grids (use AG Grid or TanStack Table)
213
- - You need editable cells, pivot tables, or group-by aggregations
214
- - You're building outside of a boring-ui workspace (you'd need the layout shell)
215
-
216
- ---
217
-
218
- ## Troubleshooting
219
-
220
- | Error | Cause | Fix |
221
- |-------|-------|-----|
222
- | No rows showing | `search()` returned empty items | Check your backend query — log the response |
223
- | Facets empty | `fetchFacets()` not implemented | Add the optional `fetchFacets` method to your adapter |
224
- | Infinite loading loop | `useExplorerState` deps changing every render | Memoize your `source` object or move it outside the component |
225
- | Drag not working | Row has no `DragPayload` | Pass `getDragPayload` prop to `<DataExplorer>` |
226
- | Wrong source selected | Adapter returns the wrong item ids or payload | Check your adapter's `search()` mapping |
227
-
228
- ---
229
-
230
- ## Limitations
231
-
232
- - **Workspace-private** — `"private": true` in package.json. Not published to npm. Install from source within the monorepo.
233
- - **Not an enterprise data grid** — No cell editing, pivot tables, or group-by. It's a search-and-filter primitive, not AG Grid.
234
- - **No built-in data fetching** — You implement `search()` and optionally `fetchFacets()`. The adapter doesn't cache or debounce.
235
- - **Requires boring-ui workspace chrome** — The component assumes it's mounted inside a workspace panel layout. Standalone use is possible but you lose the drag-to-panel behavior.
236
- - **No column customization UI** — Column shape is derived from `ExplorerItem` (title / subtitle / leading / trailing / meta) — no arbitrary column projection.
237
-
238
- ---
239
-
240
- ## FAQ
241
-
242
- **Q: What's the difference between `data-explorer` and `data-catalog`?**
243
- A: `data-explorer` is the primitive — a single table you mount wherever. `data-catalog` is a higher-level plugin: it puts a sidebar tab listing data sources, and clicking a source opens the explorer table.
244
-
245
- **Q: Can I use this without the boring-ui workspace?**
246
- A: Yes. `<DataExplorer>` and `useExplorerState` are standalone React exports. You'll just lose the drag-to-panel integration since there's no workbench to receive the payload.
247
-
248
- **Q: How do I customize the table columns?**
249
- A: Columns are derived from `ExplorerItem` fields: `title` (primary), `subtitle` (secondary), `leading` (mono chip), `trailing` (status badges), `meta` (right-aligned text). There is no per-column projection API.
250
-
251
- **Q: Does it support server-side pagination?**
252
- A: Yes — `search()` receives `limit` and `offset`. Your backend can return a slice. `total` and `hasMore` drive the pager.
253
-
254
- **Q: Can I use this for non-tabular data?**
255
- A: Yes — map any data shape (GraphQL results, CSV rows, API responses) to `ExplorerItem[]`. The adapter is backend-agnostic.
256
-
257
- ---
106
+ - Not an enterprise grid: no cell editing, pivots, group-by aggregation, or
107
+ per-column projection. Columns are derived from `ExplorerItem` fields.
108
+ - No built-in caching/debounce of fetches beyond the explorer's own search
109
+ debounce — cache at the adapter layer if needed.
110
+ - Drag-to-panel only works inside a boring-ui workspace (which receives the
111
+ payload); standalone use still renders the table.
258
112
 
259
113
  ## Used by
260
114
 
261
- - **[`@hachej/boring-data-catalog`](../data-catalog/README.md)** — configurable catalog tab listing multiple data sources
262
- - Any plugin that needs a faceted, searchable table inside a workbench panel
115
+ - `@hachej/boring-data-catalog` — wraps this into a configurable catalog tab +
116
+ visualization panel + agent tool.
263
117
 
264
- ---
118
+ ## Validation
265
119
 
266
- *About Contributions:* Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via `gh` and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.
267
-
268
- ---
120
+ ```bash
121
+ pnpm --filter @hachej/boring-data-explorer typecheck
122
+ pnpm --filter @hachej/boring-data-explorer test
123
+ pnpm --filter @hachej/boring-data-explorer build
124
+ ```
269
125
 
270
126
  ## License
271
127
 
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { ReactNode } from 'react';
2
+ import { ReactNode, ComponentType } from 'react';
3
3
  import { ExplorerDataSource, FacetConfig, ExplorerItem, DragPayload, Facets } from '../shared/index.js';
4
4
  export { Badge, FacetValue, FacetsArgs, SearchArgs, SearchResult } from '../shared/index.js';
5
5
 
@@ -16,6 +16,12 @@ type DataExplorerProps = {
16
16
  /** Empty state shown when the top-level result has no rows and no query/filters. */
17
17
  emptyState?: ReactNode;
18
18
  searchPlaceholder?: string;
19
+ /** Optional title rendered inside the explorer toolbar (for chromeless left tabs). */
20
+ toolbarTitle?: ReactNode;
21
+ /** Optional icon rendered before toolbarTitle. */
22
+ toolbarIcon?: ComponentType<{
23
+ className?: string;
24
+ }>;
19
25
  /** Hide the search input. Default true. */
20
26
  searchable?: boolean;
21
27
  /**
@@ -26,12 +32,14 @@ type DataExplorerProps = {
26
32
  query?: string;
27
33
  /** Called when the toolbar search changes. Use with `query` for controlled per-tab search. */
28
34
  onQueryChange?: (query: string) => void;
35
+ /** Optional external chrome target for toolbar-only actions such as filters. */
36
+ toolbarPortalElement?: Element | null;
29
37
  /** Page size and debounce — passed through to useExplorerState. */
30
38
  pageSize?: number;
31
39
  debounceMs?: number;
32
40
  className?: string;
33
41
  };
34
- declare function DataExplorer({ adapter, facets: facetConfigs, groupBy, onActivate, getDragPayload, emptyState, searchPlaceholder, searchable, query, onQueryChange, pageSize, debounceMs, className, }: DataExplorerProps): react_jsx_runtime.JSX.Element;
42
+ declare function DataExplorer({ adapter, facets: facetConfigs, groupBy, onActivate, getDragPayload, emptyState, searchPlaceholder, toolbarTitle, toolbarIcon, searchable, query, onQueryChange, toolbarPortalElement, pageSize, debounceMs, className, }: DataExplorerProps): react_jsx_runtime.JSX.Element;
35
43
 
36
44
  type UseExplorerStateOptions = {
37
45
  adapter: ExplorerDataSource;
@@ -1,5 +1,6 @@
1
1
  // src/front/DataExplorer.tsx
2
2
  import { useMemo } from "react";
3
+ import { createPortal } from "react-dom";
3
4
  import { ChevronRightIcon, ChevronDownIcon, FilterIcon, SearchIcon, XIcon } from "lucide-react";
4
5
 
5
6
  // src/front/utils.ts
@@ -353,9 +354,12 @@ function DataExplorer({
353
354
  getDragPayload,
354
355
  emptyState = "No results",
355
356
  searchPlaceholder = "Search\u2026",
357
+ toolbarTitle,
358
+ toolbarIcon,
356
359
  searchable = true,
357
360
  query,
358
361
  onQueryChange,
362
+ toolbarPortalElement,
359
363
  pageSize,
360
364
  debounceMs,
361
365
  className
@@ -375,6 +379,9 @@ function DataExplorer({
375
379
  const hasFilters = Object.values(state.filters).some((v) => v.length > 0);
376
380
  const treeMode = !!groupBy && !hasQuery && !hasFilters;
377
381
  const filterCount = Object.values(state.filters).reduce((n, v) => n + v.length, 0);
382
+ const useExternalToolbarActions = Boolean(
383
+ toolbarPortalElement && !showSearch && facetConfigs?.length && !toolbarTitle
384
+ );
378
385
  const groupEntries = useMemo(() => {
379
386
  if (!treeMode || !groupBy) return [];
380
387
  const config = facetConfigs?.find((f) => f.key === groupBy);
@@ -395,11 +402,27 @@ function DataExplorer({
395
402
  }, [treeMode, groupBy, facetConfigs, state.facets]);
396
403
  const showEmpty = !state.loading && !treeMode && state.topItems.length === 0 && state.query.length === 0 && !hasFilters;
397
404
  return /* @__PURE__ */ jsxs("div", { className: cn("flex h-full flex-col", className), "data-slot": "data-explorer", children: [
398
- showSearch || facetConfigs?.length ? /* @__PURE__ */ jsx(
405
+ useExternalToolbarActions && toolbarPortalElement ? createPortal(
406
+ /* @__PURE__ */ jsx(
407
+ ExternalToolbarActions,
408
+ {
409
+ facetConfigs,
410
+ facets: state.facets,
411
+ filters: state.filters,
412
+ filterCount,
413
+ onToggleFilter: state.toggleFilter,
414
+ onClearFilters: state.clearFilters
415
+ }
416
+ ),
417
+ toolbarPortalElement
418
+ ) : null,
419
+ (showSearch || facetConfigs?.length) && !useExternalToolbarActions ? /* @__PURE__ */ jsx(
399
420
  Toolbar,
400
421
  {
401
422
  searchable: showSearch,
402
423
  searchPlaceholder,
424
+ title: toolbarTitle,
425
+ icon: toolbarIcon,
403
426
  query: state.query,
404
427
  onQueryChange: setToolbarQuery,
405
428
  facetConfigs,
@@ -439,6 +462,8 @@ function DataExplorer({
439
462
  function Toolbar({
440
463
  searchable,
441
464
  searchPlaceholder,
465
+ title,
466
+ icon: Icon,
442
467
  query,
443
468
  onQueryChange,
444
469
  facetConfigs,
@@ -450,8 +475,12 @@ function Toolbar({
450
475
  total
451
476
  }) {
452
477
  return /* @__PURE__ */ jsxs(UiToolbar, { className: "border-b border-border/60 px-2 py-1.5", children: [
478
+ title ? /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-1.5 px-1", children: [
479
+ Icon ? /* @__PURE__ */ jsx(Icon, { className: "h-4 w-4 shrink-0 text-foreground/80" }) : null,
480
+ /* @__PURE__ */ jsx("span", { className: "truncate text-[14px] font-medium tracking-tight text-foreground", children: title })
481
+ ] }) : null,
453
482
  total != null ? /* @__PURE__ */ jsx("span", { className: "px-1 font-mono text-[10.5px] uppercase tracking-[0.05em] text-muted-foreground/80", children: total.toLocaleString() }) : null,
454
- /* @__PURE__ */ jsx("div", { className: "flex-1" }),
483
+ /* @__PURE__ */ jsx("div", { className: title ? "w-1 shrink-0" : "flex-1" }),
455
484
  searchable ? /* @__PURE__ */ jsxs(Popover, { children: [
456
485
  /* @__PURE__ */ jsxs(
457
486
  PopoverTrigger,
@@ -485,47 +514,71 @@ function Toolbar({
485
514
  ] }) : null
486
515
  ] })
487
516
  ] }) : null,
488
- facetConfigs?.length ? /* @__PURE__ */ jsxs(Popover, { children: [
489
- /* @__PURE__ */ jsxs(
490
- PopoverTrigger,
491
- {
492
- "aria-label": "Filters",
493
- className: cn(
494
- "inline-flex h-7 items-center gap-1 rounded-sm px-1.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground",
495
- filterCount > 0 && "bg-muted text-foreground"
496
- ),
497
- children: [
498
- /* @__PURE__ */ jsx(FilterIcon, { size: 12 }),
499
- filterCount > 0 ? /* @__PURE__ */ jsx("span", { className: "font-mono text-[10px]", children: filterCount }) : null
500
- ]
501
- }
502
- ),
503
- /* @__PURE__ */ jsxs(
504
- PopoverContent,
505
- {
506
- side: "right",
507
- align: "start",
508
- sideOffset: 8,
509
- className: "w-64 space-y-3 p-3 text-[12px]",
510
- children: [
511
- facetConfigs.map((config) => /* @__PURE__ */ jsx(
512
- FacetSection,
513
- {
514
- config,
515
- values: facets?.[config.key] ?? [],
516
- selected: filters[config.key] ?? [],
517
- onToggle: onToggleFilter
518
- },
519
- config.key
520
- )),
521
- filterCount > 0 ? /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "xs", onClick: onClearFilters, className: "gap-1 text-[11px] text-muted-foreground hover:text-foreground", children: [
522
- /* @__PURE__ */ jsx(XIcon, { size: 11 }),
523
- " Clear all"
524
- ] }) : null
525
- ]
526
- }
527
- )
528
- ] }) : null
517
+ /* @__PURE__ */ jsx(
518
+ FilterControl,
519
+ {
520
+ facetConfigs,
521
+ facets,
522
+ filters,
523
+ filterCount,
524
+ onToggleFilter,
525
+ onClearFilters
526
+ }
527
+ )
528
+ ] });
529
+ }
530
+ function ExternalToolbarActions(props) {
531
+ return /* @__PURE__ */ jsx(FilterControl, { ...props });
532
+ }
533
+ function FilterControl({
534
+ facetConfigs,
535
+ facets,
536
+ filters,
537
+ filterCount,
538
+ onToggleFilter,
539
+ onClearFilters
540
+ }) {
541
+ if (!facetConfigs?.length) return null;
542
+ return /* @__PURE__ */ jsxs(Popover, { children: [
543
+ /* @__PURE__ */ jsxs(
544
+ PopoverTrigger,
545
+ {
546
+ "aria-label": "Filters",
547
+ className: cn(
548
+ "inline-flex h-7 items-center gap-1 rounded-sm px-1.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground",
549
+ filterCount > 0 && "bg-muted text-foreground"
550
+ ),
551
+ children: [
552
+ /* @__PURE__ */ jsx(FilterIcon, { size: 12 }),
553
+ filterCount > 0 ? /* @__PURE__ */ jsx("span", { className: "font-mono text-[10px]", children: filterCount }) : null
554
+ ]
555
+ }
556
+ ),
557
+ /* @__PURE__ */ jsxs(
558
+ PopoverContent,
559
+ {
560
+ side: "right",
561
+ align: "start",
562
+ sideOffset: 8,
563
+ className: "w-64 space-y-3 p-3 text-[12px]",
564
+ children: [
565
+ facetConfigs.map((config) => /* @__PURE__ */ jsx(
566
+ FacetSection,
567
+ {
568
+ config,
569
+ values: facets?.[config.key] ?? [],
570
+ selected: filters[config.key] ?? [],
571
+ onToggle: onToggleFilter
572
+ },
573
+ config.key
574
+ )),
575
+ filterCount > 0 ? /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "xs", onClick: onClearFilters, className: "gap-1 text-[11px] text-muted-foreground hover:text-foreground", children: [
576
+ /* @__PURE__ */ jsx(XIcon, { size: 11 }),
577
+ " Clear all"
578
+ ] }) : null
579
+ ]
580
+ }
581
+ )
529
582
  ] });
530
583
  }
531
584
  function FacetSection({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-data-explorer",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "clsx": "^2.1.1",
42
42
  "lucide-react": "^1.8.0",
43
43
  "tailwind-merge": "^3.5.0",
44
- "@hachej/boring-ui-kit": "0.1.40"
44
+ "@hachej/boring-ui-kit": "0.1.42"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@testing-library/jest-dom": "^6.9.1",