@bsuite/ui 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -169,6 +169,60 @@ import { BrandingCard, OklchColorPicker } from '@bsuite/ui'
169
169
  - `favicon_url` - Favicon URL (16x16 or 32x32)
170
170
  - `company_name` - Company name for alt text
171
171
 
172
+ ## `EntitySelector` (v1.2.0+)
173
+
174
+ Generic, schema-agnostic searchable combobox for picking a row out of any
175
+ Supabase table. Extracted from crm7 (AD-5, 2026-08-17) — crm7 had 65 files of
176
+ adoption and two other apps had each started hand-porting their own copy at
177
+ the identical path. This is now the sole implementation.
178
+
179
+ Data access is dependency-injected — the package does not own a Supabase
180
+ client, since every app's client is shaped differently (Vite-SPA singleton,
181
+ per-request `createClient()` factory, distinct `storageKey`, …):
182
+
183
+ ```tsx
184
+ import { EntitySelector } from '@bsuite/ui'
185
+ import { supabase } from '@/lib/supabase' // your app's own client
186
+
187
+ <EntitySelector<Employer>
188
+ supabaseClient={supabase}
189
+ table="employers"
190
+ value={form.employer_id}
191
+ onSelect={(emp) => setValue('employer_id', emp?.id ?? null)}
192
+ displayField={(r) => r.business_name}
193
+ secondaryField={(r) => r.abn ?? r.industry}
194
+ searchColumns={['business_name', 'trading_name', 'abn']}
195
+ />
196
+ ```
197
+
198
+ **Recommended per-app pattern:** wrap the import once in a thin local file
199
+ that binds your app's client, so the rest of the app's ~dozens of call sites
200
+ never repeat `supabaseClient={supabase}`:
201
+
202
+ ```tsx
203
+ // src/components/entity/EntitySelector.tsx
204
+ import { EntitySelector as BaseEntitySelector, type EntitySelectorProps as BaseProps } from '@bsuite/ui'
205
+ import { supabase } from '@/lib/supabase'
206
+
207
+ export type EntitySelectorProps<T extends Record<string, unknown>> = Omit<BaseProps<T>, 'supabaseClient'>
208
+
209
+ export function EntitySelector<T extends Record<string, unknown>>(props: EntitySelectorProps<T>) {
210
+ return <BaseEntitySelector<T> supabaseClient={supabase} {...props} />
211
+ }
212
+ ```
213
+
214
+ **Features:** debounced typeahead (`ilike` across configured columns),
215
+ optional cross-schema reads (`schema` prop), a `filterFn` escape hatch for
216
+ arbitrary PostgREST filter chains, "one-shot" suggested options (rows already
217
+ linked on an earlier screen, shown before any typing), an async-safe
218
+ `onSelect` (no optimistic UI when the caller's select routes through an
219
+ import/RPC step), and a distinct error state (not the same "no results" copy
220
+ a genuine empty search shows).
221
+
222
+ Also exported for building custom pickers on the same visual language:
223
+ `Command`, `CommandInput`, `CommandList`, `CommandGroup`, `CommandItem`,
224
+ `CommandEmpty`, `Popover`, `PopoverTrigger`, `PopoverContent`.
225
+
172
226
  ## Utils
173
227
 
174
228
  ### `cn(...classes)`
@@ -198,6 +252,9 @@ import { BrandingCard, ColorEditorSheet, OklchColorPicker } from '@bsuite/ui/bra
198
252
  import { Button } from '@bsuite/ui/button'
199
253
  import { DialogContent } from '@bsuite/ui/dialog'
200
254
  import { EmptyState } from '@bsuite/ui/empty-state'
255
+ import { EntitySelector } from '@bsuite/ui/entity-selector'
256
+ import { Command, CommandInput } from '@bsuite/ui/command'
257
+ import { Popover, PopoverContent } from '@bsuite/ui/popover'
201
258
 
202
259
  // SVG asset
203
260
  import logoSvg from '@bsuite/ui/assets/d2c-default-logo.svg'
@@ -0,0 +1,85 @@
1
+ import { type DialogProps } from '@radix-ui/react-dialog';
2
+ import * as React from 'react';
3
+ declare const Command: React.ForwardRefExoticComponent<Omit<{
4
+ children?: React.ReactNode;
5
+ } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
6
+ ref?: React.Ref<HTMLDivElement>;
7
+ } & {
8
+ asChild?: boolean;
9
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
10
+ label?: string;
11
+ shouldFilter?: boolean;
12
+ filter?: (value: string, search: string, keywords?: string[]) => number;
13
+ defaultValue?: string;
14
+ value?: string;
15
+ onValueChange?: (value: string) => void;
16
+ loop?: boolean;
17
+ disablePointerSelection?: boolean;
18
+ vimBindings?: boolean;
19
+ } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
20
+ interface CommandDialogProps extends DialogProps {
21
+ title?: string;
22
+ description?: string;
23
+ }
24
+ declare const CommandDialog: ({ children, title, description, ...props }: CommandDialogProps) => React.JSX.Element;
25
+ declare const CommandInput: React.ForwardRefExoticComponent<Omit<Omit<Pick<Pick<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof React.InputHTMLAttributes<HTMLInputElement>> & {
26
+ ref?: React.Ref<HTMLInputElement>;
27
+ } & {
28
+ asChild?: boolean;
29
+ }, "key" | "asChild" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "type" | "onChange" | "value"> & {
30
+ value?: string;
31
+ onValueChange?: (search: string) => void;
32
+ } & React.RefAttributes<HTMLInputElement>, "ref"> & React.RefAttributes<HTMLInputElement>>;
33
+ declare const CommandList: React.ForwardRefExoticComponent<Omit<{
34
+ children?: React.ReactNode;
35
+ } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
36
+ ref?: React.Ref<HTMLDivElement>;
37
+ } & {
38
+ asChild?: boolean;
39
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
40
+ label?: string;
41
+ } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
42
+ declare const CommandEmpty: React.ForwardRefExoticComponent<Omit<{
43
+ children?: React.ReactNode;
44
+ } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
45
+ ref?: React.Ref<HTMLDivElement>;
46
+ } & {
47
+ asChild?: boolean;
48
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
49
+ declare const CommandGroup: React.ForwardRefExoticComponent<Omit<{
50
+ children?: React.ReactNode;
51
+ } & Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
52
+ ref?: React.Ref<HTMLDivElement>;
53
+ } & {
54
+ asChild?: boolean;
55
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild">, "heading" | "value"> & {
56
+ heading?: React.ReactNode;
57
+ value?: string;
58
+ forceMount?: boolean;
59
+ } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
60
+ declare const CommandSeparator: React.ForwardRefExoticComponent<Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
61
+ ref?: React.Ref<HTMLDivElement>;
62
+ } & {
63
+ asChild?: boolean;
64
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
65
+ alwaysRender?: boolean;
66
+ } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
67
+ declare const CommandItem: React.ForwardRefExoticComponent<Omit<{
68
+ children?: React.ReactNode;
69
+ } & Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
70
+ ref?: React.Ref<HTMLDivElement>;
71
+ } & {
72
+ asChild?: boolean;
73
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild">, "onSelect" | "disabled" | "value"> & {
74
+ disabled?: boolean;
75
+ onSelect?: (value: string) => void;
76
+ value?: string;
77
+ keywords?: string[];
78
+ forceMount?: boolean;
79
+ } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
80
+ declare const CommandShortcut: {
81
+ ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>): React.JSX.Element;
82
+ displayName: string;
83
+ };
84
+ export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, };
85
+ //# sourceMappingURL=command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../src/command.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAGzD,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,QAAA,MAAM,OAAO;;;;;;;;;;;;;;;;sFAYX,CAAA;AAGF,UAAU,kBAAmB,SAAQ,WAAW;IAG9C,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,QAAA,MAAM,aAAa,GAAI,4CAKpB,kBAAkB,sBAYpB,CAAA;AAED,QAAA,MAAM,YAAY;;;;;;;0FAehB,CAAA;AAIF,QAAA,MAAM,WAAW;;;;;;;;sFASf,CAAA;AAIF,QAAA,MAAM,YAAY;;;;;;uJAKhB,CAAA;AAIF,QAAA,MAAM,YAAY;;;;;;;;;;sFAYhB,CAAA;AAIF,QAAA,MAAM,gBAAgB;;;;;;sFASpB,CAAA;AAGF,QAAA,MAAM,WAAW;;;;;;;;;;;;sFAYf,CAAA;AAIF,QAAA,MAAM,eAAe;8BAA6B,KAAK,CAAC,cAAc,CAAC,eAAe,CAAC;;CAOtF,CAAA;AAGD,OAAO,EACL,OAAO,EACP,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,WAAW,EACX,gBAAgB,EAChB,eAAe,GAChB,CAAA"}
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Command as CommandPrimitive } from 'cmdk';
3
+ import { Search } from 'lucide-react';
4
+ import * as React from 'react';
5
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog.js';
6
+ import { cn } from './utils.js';
7
+ const Command = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive, { ref: ref, className: cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', className), ...props })));
8
+ Command.displayName = CommandPrimitive.displayName;
9
+ const CommandDialog = ({ children, title = 'Command palette', description = 'Search for commands, navigation, and actions', ...props }) => {
10
+ return (_jsx(Dialog, { ...props, children: _jsxs(DialogContent, { className: "overflow-hidden p-0 shadow-lg", children: [_jsx(DialogTitle, { className: "sr-only", children: title }), _jsx(DialogDescription, { className: "sr-only", children: description }), _jsx(Command, { className: "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5", children: children })] }) }));
11
+ };
12
+ const CommandInput = React.forwardRef(({ className, ...props }, ref) => (_jsxs("div", { className: "flex items-center border-b px-3", "cmdk-input-wrapper": "", children: [_jsx(Search, { className: "mr-2 h-4 w-4 shrink-0 opacity-50", "aria-hidden": "true" }), _jsx(CommandPrimitive.Input, { ref: ref, className: cn('flex h-11 w-full rounded-md bg-transparent py-3 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', className), ...props })] })));
13
+ CommandInput.displayName = CommandPrimitive.Input.displayName;
14
+ const CommandList = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.List, { ref: ref, className: cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className), ...props })));
15
+ CommandList.displayName = CommandPrimitive.List.displayName;
16
+ const CommandEmpty = React.forwardRef((props, ref) => (_jsx(CommandPrimitive.Empty, { ref: ref, className: "py-6 text-center text-sm", ...props })));
17
+ CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
18
+ const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Group, { ref: ref, className: cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', className), ...props })));
19
+ CommandGroup.displayName = CommandPrimitive.Group.displayName;
20
+ const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Separator, { ref: ref, className: cn('-mx-1 h-px bg-border', className), ...props })));
21
+ CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
22
+ const CommandItem = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Item, { ref: ref, className: cn("relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", className), ...props })));
23
+ CommandItem.displayName = CommandPrimitive.Item.displayName;
24
+ const CommandShortcut = ({ className, ...props }) => {
25
+ return (_jsx("span", { className: cn('ml-auto text-xs tracking-widest text-muted-foreground', className), ...props }));
26
+ };
27
+ CommandShortcut.displayName = 'CommandShortcut';
28
+ export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, };
@@ -0,0 +1,252 @@
1
+ /**
2
+ * EntitySelector<T>
3
+ *
4
+ * Generic searchable combobox for selecting entities from any Supabase table.
5
+ * Built on cmdk (Command) + Popover primitives shipped alongside this file in
6
+ * `@bsuite/ui`.
7
+ *
8
+ * Extracted from crm7 into `@bsuite/ui` (AD-5, 2026-08-17). crm7 had 65 files
9
+ * of adoption; throughput and conduit had each started hand-porting their own
10
+ * copy at the identical path. A shared package with the old copies still
11
+ * present is worse than no package at all — three divergent implementations
12
+ * instead of one — so this is the SOLE implementation going forward. Apps
13
+ * consume it directly (or via a thin per-app adapter that only binds their
14
+ * own Supabase client instance — see crm7's `src/components/entity/
15
+ * EntitySelector.tsx` for that pattern) rather than re-implementing it.
16
+ *
17
+ * Generalised out of crm7 in two ways, both required so this package does not
18
+ * drag a consumer's local shadcn instance or app-singleton Supabase client
19
+ * with it:
20
+ *
21
+ * 1. UI primitives (`Button`, `Command*`, `Popover*`, `StatusBadge`) are
22
+ * this package's OWN copies (`./button.js`, `./command.js`,
23
+ * `./popover.js`, `./status-badge.js`), not `@/components/ui/*` imports
24
+ * reaching back into crm7. They render from the same semantic Tailwind
25
+ * token classes (`bg-popover`, `text-muted-foreground`, …) every BSuite
26
+ * app already shares via `@bsuite/theme`, so the rendered result is
27
+ * pixel-identical to crm7's former local copy.
28
+ * 2. Data access (`supabaseClient`) and diagnostics (`logger`) are
29
+ * dependency-injected props rather than a hardcoded `@/lib/supabase` /
30
+ * `@/utils/logger` import — every app owns a differently-shaped
31
+ * Supabase client (crm7: a Vite-SPA singleton; conduit: a per-request
32
+ * `createClient()` factory via `@supabase/ssr`), and this package
33
+ * cannot assume either.
34
+ *
35
+ * Features:
36
+ * - Debounced typeahead search against Supabase (ilike across configured columns)
37
+ * - Shows primary + secondary display fields per result
38
+ * - Returns the full row object on select (enables auto-populate in parent forms)
39
+ * - Optional "quick-add" button for inline entity creation
40
+ * - For small datasets (<50 rows) loads all on open; for larger sets requires typing
41
+ * - Supports external value control via `value` prop
42
+ *
43
+ * Usage:
44
+ * <EntitySelector<Employer>
45
+ * supabaseClient={supabase}
46
+ * table="employers"
47
+ * value={form.employer_id}
48
+ * onSelect={(emp) => { setValue('employer_id', emp.id); autoFill(emp); }}
49
+ * displayField={(r) => r.business_name}
50
+ * secondaryField={(r) => r.abn ?? r.industry}
51
+ * searchColumns={['business_name', 'trading_name', 'abn']}
52
+ * />
53
+ */
54
+ import type { PostgrestFilterBuilder } from '@supabase/postgrest-js';
55
+ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
56
+ /**
57
+ * Schema-agnostic Supabase filter-builder alias used by `filterFn`.
58
+ *
59
+ * `PostgrestFilterBuilder` is parameterised over the concrete DB schema,
60
+ * row, result, and relationship types — which the EntitySelector factory
61
+ * deliberately does not know at this layer (each selector wraps its own
62
+ * `T`). Using `any` for the four generic positions is the canonical escape
63
+ * hatch in `@supabase/postgrest-js` for schema-agnostic filter-builder
64
+ * wrappers. Centralising it here (rather than `(query: any) => any` inline)
65
+ * gives call sites stable input/output types so they get method completion
66
+ * on `.eq()` / `.in()` / `.gte()` chains and no longer need `as typeof
67
+ * query` casts.
68
+ */
69
+ export type EntitySelectorQuery = PostgrestFilterBuilder<any, any, any, any>;
70
+ /**
71
+ * Minimal diagnostics sink. Defaults to `console` so a consumer that omits
72
+ * `logger` still sees failures during development rather than a silent
73
+ * swallow; production apps typically inject their own structured logger.
74
+ */
75
+ export interface EntitySelectorLogger {
76
+ warn: (...args: unknown[]) => void;
77
+ error: (...args: unknown[]) => void;
78
+ }
79
+ /**
80
+ * Structural shape this component needs from a Supabase client — NOT the
81
+ * nominal `SupabaseClient` class type from `@supabase/supabase-js`.
82
+ *
83
+ * Deliberate: this package's own `@supabase/supabase-js` (a devDependency,
84
+ * used only for the type) and a consumer's own `@supabase/supabase-js` (a
85
+ * peer dependency, resolved from THEIR lockfile) can end up as two
86
+ * different installed instances in a pnpm monorepo where every app pins
87
+ * its own floating range independently — crm7's own copy has drifted to
88
+ * 2.110.8 against this package's 2.108.1, for instance. TypeScript treats
89
+ * two structurally-identical classes sourced from different physical
90
+ * packages as nominally incompatible ("not assignable" even though every
91
+ * member matches), which would make a real consumer's real client fail to
92
+ * satisfy a nominal `SupabaseClient` prop type for no functional reason.
93
+ * A structural interface sidesteps the whole class of failure: any client
94
+ * with these two methods satisfies it, regardless of which supabase-js
95
+ * instance produced it. The query builder itself is intentionally
96
+ * `any`-erased past this point — see {@link EntitySelectorQuery}, the
97
+ * same escape hatch this component already uses for `filterFn`.
98
+ */
99
+ export interface EntitySelectorSupabaseClient {
100
+ from: (table: string) => any;
101
+ schema: (schemaName: string) => {
102
+ from: (table: string) => any;
103
+ };
104
+ }
105
+ export interface EntitySelectorProps<T extends Record<string, unknown>> {
106
+ /**
107
+ * The consumer's own Supabase client instance. `@bsuite/ui` does not own a
108
+ * client — each app's client is shaped differently (singleton vs
109
+ * per-request factory, different `storageKey`, etc.) — so this is
110
+ * required rather than imported internally.
111
+ */
112
+ supabaseClient: EntitySelectorSupabaseClient;
113
+ /** Diagnostics sink for non-fatal query failures. Defaults to `console`. */
114
+ logger?: EntitySelectorLogger;
115
+ /** Supabase table name */
116
+ table: string;
117
+ /**
118
+ * Optional non-`public` Postgres schema (e.g. `'catalog'`). When set, the
119
+ * query is built via `supabaseClient.schema(schema).from(table)` instead
120
+ * of `supabaseClient.from(table)`. PostgREST embeds do not cross schema
121
+ * boundaries, and cross-schema reads additionally require the querying
122
+ * role (`authenticated`/`anon`) to hold `USAGE` on that schema, which is a
123
+ * DB grant this component cannot verify or fix at runtime. A failed read
124
+ * surfaces through the existing `error` state below (role="alert"), the
125
+ * same path used for a bad embed hint — never a silent "no results".
126
+ */
127
+ schema?: string;
128
+ /** Currently selected entity ID (controlled) */
129
+ value?: string;
130
+ /**
131
+ * Called with the full row when user selects; null when cleared.
132
+ *
133
+ * May return a Promise (import-on-select: a catalogue selector resolves
134
+ * the pick through an RPC before committing it). When it does,
135
+ * `handleItemSelect` below does NOT optimistically show the clicked row —
136
+ * see that function's comment for why an optimistic update is actively
137
+ * wrong for an async `onSelect`.
138
+ */
139
+ onSelect: (entity: T | null) => void | Promise<void>;
140
+ /** How to render the primary display text from a row */
141
+ displayField: (row: T) => string;
142
+ /** Optional secondary line (e.g. ABN, email) */
143
+ secondaryField?: (row: T) => string;
144
+ /** Columns to search via ilike */
145
+ searchColumns: readonly string[];
146
+ /** Supabase select string — supports JOINs e.g. "*, contacts(first_name)" */
147
+ selectColumns?: string;
148
+ /**
149
+ * Additional Supabase query filters (e.g. status eq active). Receives
150
+ * and returns a schema-agnostic {@link EntitySelectorQuery} so consumers
151
+ * get stable input/output typing and `.eq()` / `.in()` / `.gte()` method
152
+ * completion without needing `as typeof query` casts at each call site.
153
+ */
154
+ filterFn?: (query: EntitySelectorQuery) => EntitySelectorQuery;
155
+ /** Placeholder text */
156
+ placeholder?: string;
157
+ /** Disabled state */
158
+ disabled?: boolean;
159
+ /** Show a "+" quick-add button; callback fired on click */
160
+ onQuickAdd?: () => void;
161
+ /** Quick-add button label */
162
+ quickAddLabel?: string;
163
+ /** Min characters before searching (0 = load on open) */
164
+ minChars?: number;
165
+ /** Debounce in ms */
166
+ debounceMs?: number;
167
+ /** Max results to fetch */
168
+ limit?: number;
169
+ /** Allow clearing the selection */
170
+ clearable?: boolean;
171
+ /** Additional className for the trigger button */
172
+ className?: string;
173
+ /** Label shown in the trigger when an entity is selected */
174
+ selectedLabel?: string;
175
+ /**
176
+ * Notifies the parent when the selector's query state changes so the
177
+ * parent form can gate submission ("Resolving company…") while the
178
+ * typeahead is in-flight. Fires on every loading-state transition plus
179
+ * when the query text changes.
180
+ */
181
+ onQueryStateChange?: (state: {
182
+ isLoading: boolean;
183
+ query: string;
184
+ }) => void;
185
+ /**
186
+ * Optional custom row renderer. When provided, replaces the default
187
+ * display/secondary field layout for each result row, letting selectors
188
+ * visually distinguish inactive/discontinued rows.
189
+ *
190
+ * The renderer receives the row plus a selection indicator so custom
191
+ * implementations can still draw a check/highlight for the currently
192
+ * selected entity. The wrapping `<CommandItem>` (with its own click /
193
+ * keyboard handler) is supplied by EntitySelector — `renderRow` only
194
+ * renders the row's inner content.
195
+ */
196
+ renderRow?: (row: T, meta: {
197
+ isSelected: boolean;
198
+ }) => ReactNode;
199
+ /** Form control id for aria-labelledby */
200
+ id?: ComponentPropsWithoutRef<'button'>['id'];
201
+ /** Form control name attribute */
202
+ name?: ComponentPropsWithoutRef<'button'>['name'];
203
+ /** Form control aria-describedby */
204
+ 'aria-describedby'?: ComponentPropsWithoutRef<'button'>['aria-describedby'];
205
+ /** Form control aria-invalid */
206
+ 'aria-invalid'?: ComponentPropsWithoutRef<'button'>['aria-invalid'];
207
+ /** Form control aria-labelledby */
208
+ 'aria-labelledby'?: ComponentPropsWithoutRef<'button'>['aria-labelledby'];
209
+ /**
210
+ * Import-on-select: when a selector SEARCHES one table/schema but `value`
211
+ * is a foreign key into a DIFFERENT table (e.g. a catalogue selector
212
+ * searches `catalog.qualifications` but the owning column — and
213
+ * therefore `value` — points at `public.qualifications`, the tenant's
214
+ * own copy created on select), these three let the "resolve an
215
+ * already-selected value" lookup target that different table instead of
216
+ * `table`/`schema`/`selectColumns`. Default (all three omitted) is
217
+ * today's behaviour: resolve from the same place search runs against —
218
+ * every existing caller is unaffected.
219
+ */
220
+ resolveValueTable?: string;
221
+ resolveValueSchema?: string;
222
+ resolveValueSelectColumns?: string;
223
+ /**
224
+ * ONE-SHOT: ids already linked to this record through an earlier screen.
225
+ *
226
+ * DRY is about one row in one table. This is about the USER never typing
227
+ * the same thing twice: the second screen in a workflow surfaces what the
228
+ * first screen already established, so the answer is one click rather
229
+ * than a search.
230
+ *
231
+ * These are fetched BY ID and shown in their own group at the top, before
232
+ * any typing. That matters — `minChars` means an ordinary picker shows
233
+ * nothing at all until the user types, which is exactly the moment they
234
+ * are being asked to re-derive something the system already knows.
235
+ * Suggested rows bypass that.
236
+ *
237
+ * This narrows nothing. The full catalogue stays searchable underneath,
238
+ * so a genuinely new choice is never blocked — the same
239
+ * advise-don't-block posture the rest of this domain runs on.
240
+ */
241
+ suggestedIds?: readonly string[];
242
+ /**
243
+ * Heading for the suggested group. Say WHERE the options came from — "From
244
+ * Teagan Taylor's record", "Preferred by Built Management Services" —
245
+ * because a bare "Suggested" gives the user no way to judge whether to
246
+ * trust it.
247
+ */
248
+ suggestedLabel?: string;
249
+ }
250
+ export declare function EntitySelector<T extends Record<string, unknown>>({ supabaseClient, logger, table, schema, value, onSelect, displayField, secondaryField, searchColumns, selectColumns, filterFn, placeholder, disabled, onQuickAdd, quickAddLabel, minChars, debounceMs, limit, clearable, className, selectedLabel, onQueryStateChange, renderRow, id, name, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-labelledby': ariaLabelledBy, resolveValueTable, resolveValueSchema, resolveValueSelectColumns, suggestedIds, suggestedLabel, }: EntitySelectorProps<T>): import("react").JSX.Element;
251
+ export default EntitySelector;
252
+ //# sourceMappingURL=entity-selector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-selector.d.ts","sourceRoot":"","sources":["../src/entity-selector.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,297 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Check, ChevronsUpDown, Loader2, Plus, X } from 'lucide-react';
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { Button } from './button.js';
5
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from './command.js';
6
+ import { Popover, PopoverContent, PopoverTrigger } from './popover.js';
7
+ import { StatusBadge } from './status-badge.js';
8
+ import { cn } from './utils.js';
9
+ // ============================================================================
10
+ // COMPONENT
11
+ // ============================================================================
12
+ export function EntitySelector({ supabaseClient, logger = console, table, schema, value, onSelect, displayField, secondaryField, searchColumns, selectColumns = '*', filterFn, placeholder = 'Search...', disabled = false, onQuickAdd, quickAddLabel = 'Create new', minChars = 0, debounceMs = 250, limit = 50, clearable = true, className, selectedLabel, onQueryStateChange, renderRow, id, name, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-labelledby': ariaLabelledBy, resolveValueTable, resolveValueSchema, resolveValueSelectColumns, suggestedIds, suggestedLabel = 'Already linked', }) {
13
+ const [open, setOpen] = useState(false);
14
+ const [query, setQuery] = useState('');
15
+ const [items, setItems] = useState([]);
16
+ const [suggestedItems, setSuggestedItems] = useState([]);
17
+ const [isLoading, setIsLoading] = useState(false);
18
+ const [selectedItem, setSelectedItem] = useState(null);
19
+ /**
20
+ * A failed search (bad `selectColumns` embed, RLS denial, network error,
21
+ * etc.) must not fall through to `setItems([])` with only a logger
22
+ * side-channel — the rendered dropdown would show the exact same
23
+ * `CommandEmpty` "No items found" copy as a genuine zero-result search,
24
+ * which is indistinguishable from the user's perspective. Tracking the
25
+ * error message separately lets the render below show a distinct,
26
+ * `role="alert"` state instead.
27
+ */
28
+ const [error, setError] = useState(null);
29
+ const debounceRef = useRef(null);
30
+ const hasLoadedInitial = useRef(false);
31
+ /**
32
+ * Returns a schema-scoped `.from(table)` builder. Defaults to the public
33
+ * schema (identical to the pre-existing `supabaseClient.from(table)`
34
+ * behaviour) unless `schema` is supplied, in which case it queries via
35
+ * `supabaseClient.schema(schema).from(table)` — see the `schema` prop doc.
36
+ *
37
+ * {@link EntitySelectorSupabaseClient} is structurally typed for the same
38
+ * reason {@link EntitySelectorQuery} is `any`-erased: this package has no
39
+ * generated `Database` type to parameterise against, and each consumer's
40
+ * own generated types differ. `.schema()` is likewise unparameterised
41
+ * here — a schema-agnostic escape hatch, not a typing gap introduced by
42
+ * this component.
43
+ */
44
+ const fromTable = useCallback(() => {
45
+ if (schema) {
46
+ return supabaseClient.schema(schema).from(table);
47
+ }
48
+ return supabaseClient.from(table);
49
+ }, [schema, table, supabaseClient]);
50
+ /**
51
+ * Table/schema/columns for resolving an already-selected `value` into a
52
+ * display row. Defaults to the search table when `resolveValueTable` is
53
+ * omitted — see the prop doc for why these can differ.
54
+ */
55
+ const resolveTableName = resolveValueTable ?? table;
56
+ const resolveSchemaName = resolveValueSchema ?? schema;
57
+ const resolveSelectColumns = resolveValueSelectColumns ?? selectColumns;
58
+ const fromResolveTable = useCallback(() => {
59
+ if (resolveSchemaName) {
60
+ return supabaseClient.schema(resolveSchemaName).from(resolveTableName);
61
+ }
62
+ return supabaseClient.from(resolveTableName);
63
+ }, [resolveSchemaName, resolveTableName, supabaseClient]);
64
+ // ── Resolve selected item from value prop ──────────────────────────────
65
+ useEffect(() => {
66
+ if (!value) {
67
+ setSelectedItem(null);
68
+ return;
69
+ }
70
+ // If we already have this item loaded, use it — but only when the
71
+ // search results and the resolve target are the SAME table (the
72
+ // `resolveValueTable` override case means `items` come from a
73
+ // different table than `value` points at, so a matching `id` there
74
+ // would be coincidental, not correct).
75
+ if (!resolveValueTable) {
76
+ const existing = items.find((i) => String(i.id) === String(value));
77
+ if (existing) {
78
+ setSelectedItem(existing);
79
+ return;
80
+ }
81
+ }
82
+ // Otherwise fetch the single entity by ID
83
+ let cancelled = false;
84
+ (async () => {
85
+ const { data } = await fromResolveTable()
86
+ .select(resolveSelectColumns)
87
+ .eq('id', value)
88
+ .single();
89
+ if (!cancelled && data) {
90
+ setSelectedItem(data);
91
+ }
92
+ })();
93
+ return () => {
94
+ cancelled = true;
95
+ };
96
+ }, [value, resolveValueTable, resolveTableName, resolveSelectColumns, fromResolveTable]); // eslint-disable-line react-hooks/exhaustive-deps
97
+ // ── Search / load items ────────────────────────────────────────────────
98
+ const fetchItems = useCallback(async (searchTerm) => {
99
+ setIsLoading(true);
100
+ setError(null);
101
+ try {
102
+ // Supabase `.select()` returns a narrow `PostgrestTransformBuilder`
103
+ // subtype that carries the concrete row schema. We widen it to the
104
+ // schema-agnostic {@link EntitySelectorQuery} alias so the public
105
+ // `filterFn` prop type (which is unparameterised by design) can
106
+ // consume and return it without callers needing `as` casts. The
107
+ // single widen-cast here replaces the casts every inner selector
108
+ // would otherwise need.
109
+ let q = fromTable().select(selectColumns);
110
+ // Apply custom filters
111
+ if (filterFn) {
112
+ q = filterFn(q);
113
+ }
114
+ // Apply search if term is long enough
115
+ if (searchTerm.length >= Math.max(minChars, 1)) {
116
+ const escaped = searchTerm
117
+ .replace(/\\/g, '\\\\')
118
+ .replace(/"/g, '\\"')
119
+ .replace(/,/g, '\\,')
120
+ .replace(/\(/g, '\\(')
121
+ .replace(/\)/g, '\\)');
122
+ const term = `%${escaped}%`;
123
+ const orConditions = searchColumns.map((col) => `${col}.ilike.${term}`).join(',');
124
+ q = q.or(orConditions);
125
+ }
126
+ q = q.limit(limit);
127
+ const { data, error: queryError } = await q;
128
+ if (queryError) {
129
+ logger.warn(`EntitySelector(${table}): search error`, queryError.message);
130
+ setError(queryError.message || 'Failed to load results.');
131
+ setItems([]);
132
+ return;
133
+ }
134
+ setItems(data ?? []);
135
+ }
136
+ catch (err) {
137
+ logger.error(`EntitySelector(${table}): unexpected error`, err);
138
+ setError(err instanceof Error ? err.message : 'Failed to load results.');
139
+ setItems([]);
140
+ }
141
+ finally {
142
+ setIsLoading(false);
143
+ }
144
+ }, [table, selectColumns, searchColumns, filterFn, minChars, limit, fromTable, logger]);
145
+ /**
146
+ * ONE-SHOT: fetch the already-linked rows BY ID.
147
+ *
148
+ * Deliberately a separate query from the search, for two reasons. It must not
149
+ * be subject to `minChars` — the whole point is that the user sees the answer
150
+ * without typing — and it must not be subject to `limit`, or a suggestion
151
+ * could fall off the end of a busy catalogue and silently stop being offered.
152
+ *
153
+ * `filterFn` is NOT applied. A suggestion is already an established link made
154
+ * by this user on an earlier screen; re-filtering it here could hide a row the
155
+ * system itself put there, which reads as the feature being broken.
156
+ */
157
+ const suggestedKey = suggestedIds?.length ? [...suggestedIds].sort().join(',') : '';
158
+ useEffect(() => {
159
+ if (!open || !suggestedKey) {
160
+ if (!suggestedKey)
161
+ setSuggestedItems([]);
162
+ return;
163
+ }
164
+ let cancelled = false;
165
+ void (async () => {
166
+ try {
167
+ const { data, error: sErr } = await fromTable()
168
+ .select(selectColumns)
169
+ .in('id', suggestedKey.split(','));
170
+ if (cancelled)
171
+ return;
172
+ if (sErr) {
173
+ // Never surface this as a page error — the catalogue below still
174
+ // works, so a failed suggestion degrades to an ordinary search
175
+ // rather than blocking the user.
176
+ logger.warn(`EntitySelector(${table}): suggested fetch failed`, sErr.message);
177
+ setSuggestedItems([]);
178
+ return;
179
+ }
180
+ setSuggestedItems(data ?? []);
181
+ }
182
+ catch (err) {
183
+ if (!cancelled) {
184
+ logger.warn(`EntitySelector(${table}): suggested fetch threw`, err);
185
+ setSuggestedItems([]);
186
+ }
187
+ }
188
+ })();
189
+ return () => {
190
+ cancelled = true;
191
+ };
192
+ }, [open, suggestedKey, fromTable, selectColumns, table, logger]);
193
+ /**
194
+ * One row, shared by the suggested group and the catalogue group.
195
+ *
196
+ * Extracted rather than duplicated: the two groups must stay visually and
197
+ * behaviourally identical, and a copy is how one of them quietly loses the
198
+ * selected-check or the `renderRow` override later.
199
+ */
200
+ const renderItem = (item) => {
201
+ const itemId = String(item.id);
202
+ const isSelected = String(value) === itemId;
203
+ return (_jsx(CommandItem, { value: itemId, onSelect: () => handleItemSelect(item), className: "flex items-center justify-between", children: renderRow ? (renderRow(item, { isSelected })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col min-w-0", children: [_jsx("span", { className: "font-medium truncate", children: displayField(item) }), secondaryField && (_jsx("span", { className: "text-xs text-muted-foreground truncate", children: secondaryField(item) }))] }), isSelected && _jsx(Check, { className: "h-4 w-4 shrink-0 text-primary" })] })) }, itemId));
204
+ };
205
+ // ── Load items when popover opens (for small datasets / minChars=0) ───
206
+ useEffect(() => {
207
+ if (open && !hasLoadedInitial.current) {
208
+ hasLoadedInitial.current = true;
209
+ fetchItems('');
210
+ }
211
+ }, [open, fetchItems]);
212
+ // ── Debounced search on query change ───────────────────────────────────
213
+ const handleQueryChange = useCallback((val) => {
214
+ setQuery(val);
215
+ if (debounceRef.current)
216
+ clearTimeout(debounceRef.current);
217
+ debounceRef.current = setTimeout(() => fetchItems(val), debounceMs);
218
+ }, [fetchItems, debounceMs]);
219
+ // ── Surface loading+query state upward so the parent form can gate submit ─
220
+ useEffect(() => {
221
+ if (onQueryStateChange) {
222
+ onQueryStateChange({ isLoading, query });
223
+ }
224
+ }, [isLoading, query, onQueryStateChange]);
225
+ // Cleanup debounce on unmount
226
+ useEffect(() => {
227
+ return () => {
228
+ if (debounceRef.current)
229
+ clearTimeout(debounceRef.current);
230
+ };
231
+ }, []);
232
+ // ── Selection handlers ─────────────────────────────────────────────────
233
+ /**
234
+ * A PRIOR version of this fix had an import-on-select consumer call the
235
+ * parent's `onSelect(null)` on every import-RPC failure, meant to clear a
236
+ * stale optimistic display. That made things worse: in EDIT mode `value`
237
+ * starts as the SAVED entity id, and `onSelect(null)` overwrote the
238
+ * parent form's state with `undefined` — wiping a link the database
239
+ * actually has, the exact inverse of the bug being fixed, on every failed
240
+ * re-pick.
241
+ *
242
+ * The real defect is here, not in the caller: this function set
243
+ * `selectedItem` SYNCHRONOUSLY AND OPTIMISTICALLY on every click, before
244
+ * `onSelect` (which may be async — see the prop doc) had any chance to
245
+ * fail. For a synchronous `onSelect` (the overwhelming majority of
246
+ * consumers) that is correct — there is no failure mode to protect
247
+ * against. For an async `onSelect` (routing the pick through an
248
+ * import-from-catalogue RPC, say), setting `selectedItem` before the
249
+ * round-trip resolves means: on failure, the trigger shows a row the
250
+ * database was never given, and it stays wrong because `value` never
251
+ * changed, so the "resolve selected item from value" effect (above) has
252
+ * nothing to react to and never corrects it.
253
+ *
254
+ * Fix: detect a thenable return and skip the optimistic update entirely.
255
+ * The eventual state is then driven ONLY by `value`:
256
+ * - success: the async `onSelect` calls the caller's real setter with
257
+ * the new (imported) id, `value` changes, the resolve-effect fetches
258
+ * and displays it — one extra network round trip, correct result.
259
+ * - failure: the async `onSelect` does NOT call the caller's setter,
260
+ * `value` is untouched, `selectedItem` was never touched either — a
261
+ * fresh form keeps showing the placeholder, an edit form keeps
262
+ * showing the saved entity. Neither a false assertion nor a false
263
+ * wipe.
264
+ */
265
+ const handleItemSelect = (item) => {
266
+ const result = onSelect(item);
267
+ if (!result || typeof result.then !== 'function') {
268
+ setSelectedItem(item);
269
+ }
270
+ setOpen(false);
271
+ setQuery('');
272
+ };
273
+ const handleClear = (e) => {
274
+ e.stopPropagation();
275
+ setSelectedItem(null);
276
+ onSelect(null);
277
+ };
278
+ // ── Render ─────────────────────────────────────────────────────────────
279
+ const triggerLabel = selectedItem ? (selectedLabel ?? displayField(selectedItem)) : placeholder;
280
+ return (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": open, id: id, name: name, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-labelledby": ariaLabelledBy, disabled: disabled, className: cn('w-full justify-between font-normal', !selectedItem && 'text-muted-foreground', className), children: [_jsx("span", { className: "truncate min-w-0", children: triggerLabel }), _jsxs("div", { className: "flex items-center gap-1 ml-2 shrink-0", children: [clearable && selectedItem && (_jsx(X, { className: "h-3.5 w-3.5 opacity-50 hover:opacity-100", onClick: handleClear })), _jsx(ChevronsUpDown, { className: "h-3.5 w-3.5 opacity-50" })] })] }) }), _jsx(PopoverContent, { className: "w-[--radix-popover-trigger-width] p-0", align: "start", children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: placeholder, value: query, onValueChange: handleQueryChange }), _jsxs(CommandList, { children: [isLoading ? (_jsxs("div", { className: "flex items-center justify-center py-6", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin text-muted-foreground" }), _jsx("span", { className: "ml-2 text-sm text-muted-foreground", children: "Searching..." })] })) : error ? (_jsxs("div", { className: "text-center py-6 px-4 text-sm text-error-text", role: "alert", children: ["Couldn't load results: ", error] })) : items.length === 0 && suggestedItems.length === 0 ? (_jsx(CommandEmpty, { children: query.length > 0 ? `No results for "${query}"` : 'No items found' })) : (_jsxs(_Fragment, { children: [suggestedItems.length > 0 && query.length === 0 && (_jsx(CommandGroup, { heading: suggestedLabel, children: suggestedItems.map((item) => renderItem(item)) })), (() => {
281
+ // De-dupe so a suggested row is not offered twice; showing
282
+ // the same entity in both groups reads as two different
283
+ // records.
284
+ const shown = suggestedItems.length > 0 && query.length === 0
285
+ ? items.filter((i) => !suggestedItems.some((s) => String(s.id) === String(i.id)))
286
+ : items;
287
+ if (shown.length === 0)
288
+ return null;
289
+ return (_jsx(CommandGroup, { heading: suggestedItems.length > 0 && query.length === 0
290
+ ? 'All options'
291
+ : undefined, children: shown.map((item) => renderItem(item)) }));
292
+ })()] })), onQuickAdd && (_jsx("div", { className: "border-t p-1", children: _jsxs(Button, { variant: "ghost", size: "sm", className: "w-full justify-start text-muted-foreground", onClick: () => {
293
+ setOpen(false);
294
+ onQuickAdd();
295
+ }, children: [_jsx(Plus, { className: "h-4 w-4 mr-2" }), quickAddLabel] }) })), items.length > 0 && (_jsx("div", { className: "border-t px-2 py-1.5 text-center", children: _jsxs(StatusBadge, { tone: "neutral", className: "text-[10px]", children: [items.length, items.length >= limit ? '+' : '', " result", items.length !== 1 ? 's' : ''] }) }))] })] }) })] }), onQuickAdd && (_jsxs(Button, { type: "button", variant: "outline", size: quickAddLabel && quickAddLabel.length > 12 ? 'sm' : 'icon', className: quickAddLabel && quickAddLabel.length > 12 ? 'shrink-0 whitespace-nowrap' : 'shrink-0', disabled: disabled, onClick: onQuickAdd, title: quickAddLabel, "aria-label": quickAddLabel, "data-testid": "entity-selector-quick-add", children: [_jsx(Plus, { className: "h-4 w-4" }), quickAddLabel && quickAddLabel.length > 12 ? (_jsx("span", { className: "ml-1.5 max-w-[10rem] truncate sm:max-w-none", children: quickAddLabel })) : null] }))] }));
296
+ }
297
+ export default EntitySelector;
package/dist/index.d.ts CHANGED
@@ -11,6 +11,9 @@ export { DataUnavailable, resolveDataState, describeError, type DataUnavailableP
11
11
  export { ErrorBoundary, type ErrorBoundaryProps } from './error-boundary.js';
12
12
  export { LoadingSpinner, type LoadingSpinnerProps } from './loading-spinner.js';
13
13
  export { StatusBadge, type StatusBadgeProps, type StatusBadgeTone } from './status-badge.js';
14
+ export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from './command.js';
15
+ export { Popover, PopoverTrigger, PopoverContent } from './popover.js';
16
+ export { EntitySelector, type EntitySelectorProps, type EntitySelectorQuery, type EntitySelectorLogger, type EntitySelectorSupabaseClient, } from './entity-selector.js';
14
17
  export { BrandingCard, ColorEditorSheet, OklchColorPicker, type BrandingCardProps, type ColorEditorSheetProps, type OklchColorPickerProps, } from './branding/index.js';
15
18
  export { sanitizeCustomCss } from './branding/sanitizeCustomCss.js';
16
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,EAAE,EAAE,EAAE,MAAM,YAAY,CAAA;AAC/B,OAAO,EACL,IAAI,EACJ,cAAc,EACd,KAAK,SAAS,EACd,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,OAAO,GACb,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAC5E,OAAO,EACL,MAAM,EACN,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,MAAM,EACN,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAC5E,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC/E,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAG5F,OAAO,EACL,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,iBAAiB,EACtB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,EAAE,EAAE,EAAE,MAAM,YAAY,CAAA;AAC/B,OAAO,EACL,IAAI,EACJ,cAAc,EACd,KAAK,SAAS,EACd,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,OAAO,GACb,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAC5E,OAAO,EACL,MAAM,EACN,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,MAAM,EACN,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAC5E,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC/E,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAC5F,OAAO,EACL,OAAO,EACP,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,WAAW,EACX,gBAAgB,EAChB,eAAe,GAChB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AACtE,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,4BAA4B,GAClC,MAAM,sBAAsB,CAAA;AAG7B,OAAO,EACL,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,iBAAiB,EACtB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAA"}
package/dist/index.js CHANGED
@@ -11,6 +11,9 @@ export { DataUnavailable, resolveDataState, describeError, } from './data-unavai
11
11
  export { ErrorBoundary } from './error-boundary.js';
12
12
  export { LoadingSpinner } from './loading-spinner.js';
13
13
  export { StatusBadge } from './status-badge.js';
14
+ export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from './command.js';
15
+ export { Popover, PopoverTrigger, PopoverContent } from './popover.js';
16
+ export { EntitySelector, } from './entity-selector.js';
14
17
  // Branding components (v0.3.0+)
15
18
  export { BrandingCard, ColorEditorSheet, OklchColorPicker, } from './branding/index.js';
16
19
  export { sanitizeCustomCss } from './branding/sanitizeCustomCss.js';
@@ -0,0 +1,7 @@
1
+ import * as React from 'react';
2
+ import * as PopoverPrimitive from '@radix-ui/react-popover';
3
+ declare const Popover: React.FC<PopoverPrimitive.PopoverProps>;
4
+ declare const PopoverTrigger: React.ForwardRefExoticComponent<PopoverPrimitive.PopoverTriggerProps & React.RefAttributes<HTMLButtonElement>>;
5
+ declare const PopoverContent: React.ForwardRefExoticComponent<Omit<PopoverPrimitive.PopoverContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
6
+ export { Popover, PopoverTrigger, PopoverContent };
7
+ //# sourceMappingURL=popover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popover.d.ts","sourceRoot":"","sources":["../src/popover.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,gBAAgB,MAAM,yBAAyB,CAAA;AAI3D,QAAA,MAAM,OAAO,yCAAwB,CAAA;AAErC,QAAA,MAAM,cAAc,gHAA2B,CAAA;AAE/C,QAAA,MAAM,cAAc,gKAgBlB,CAAA;AAGF,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,CAAA"}
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import * as PopoverPrimitive from '@radix-ui/react-popover';
4
+ import { cn } from './utils.js';
5
+ const Popover = PopoverPrimitive.Root;
6
+ const PopoverTrigger = PopoverPrimitive.Trigger;
7
+ const PopoverContent = React.forwardRef(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (_jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Content, { ref: ref, align: align, sideOffset: sideOffset, className: cn('z-50 w-72 rounded-md bg-popover p-4 text-popover-foreground shadow-lg outline-none 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
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
9
+ export { Popover, PopoverTrigger, PopoverContent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsuite/ui",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -67,6 +67,18 @@
67
67
  "import": "./dist/status-badge.js",
68
68
  "types": "./dist/status-badge.d.ts"
69
69
  },
70
+ "./command": {
71
+ "import": "./dist/command.js",
72
+ "types": "./dist/command.d.ts"
73
+ },
74
+ "./popover": {
75
+ "import": "./dist/popover.js",
76
+ "types": "./dist/popover.d.ts"
77
+ },
78
+ "./entity-selector": {
79
+ "import": "./dist/entity-selector.js",
80
+ "types": "./dist/entity-selector.d.ts"
81
+ },
70
82
  "./assets/d2c-default-logo.svg": "./src/assets/d2c-default-logo.svg"
71
83
  },
72
84
  "scripts": {
@@ -78,23 +90,31 @@
78
90
  },
79
91
  "dependencies": {
80
92
  "@radix-ui/react-dialog": "^1.1.16",
93
+ "@radix-ui/react-popover": "^1.1.16",
81
94
  "clsx": "^2.1.1",
95
+ "cmdk": "^1.1.1",
96
+ "lucide-react": "^1.17.0",
82
97
  "react-error-boundary": "^6.1.2",
83
98
  "tailwind-merge": "^3.6.0"
84
99
  },
85
100
  "peerDependencies": {
86
101
  "@bsuite/theme": ">=0.7.0",
102
+ "@supabase/postgrest-js": "^2",
87
103
  "react": "^18.3.0 || ^19.0.0",
88
104
  "react-dom": "^18.3.0 || ^19.0.0"
89
105
  },
90
106
  "peerDependenciesMeta": {
91
107
  "@bsuite/theme": {
92
108
  "optional": true
109
+ },
110
+ "@supabase/postgrest-js": {
111
+ "optional": true
93
112
  }
94
113
  },
95
114
  "devDependencies": {
96
115
  "@bsuite/theme": "^0.11.0",
97
116
  "@storybook/react-vite": "^10.5.8",
117
+ "@supabase/postgrest-js": "^2.108.1",
98
118
  "@tailwindcss/vite": "^4.3.0",
99
119
  "@testing-library/jest-dom": "^6.9.1",
100
120
  "@testing-library/react": "^16.3.2",