@bsuite/ui 1.1.0 → 1.2.2

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'
@@ -1 +1 @@
1
- {"version":3,"file":"BrandingCard.d.ts","sourceRoot":"","sources":["../../src/branding/BrandingCard.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAGhD,MAAM,WAAW,iBAAiB;IAChC,qCAAqC;IACrC,QAAQ,EAAE;QACR,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,cAAc,CAAC,EAAE,MAAM,CAAA;QACvB,aAAa,CAAC,EAAE,MAAM,CAAA;QACtB,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,YAAY,CAAC,EAAE,MAAM,CAAA;QACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAC3B,CAAA;IACD,mDAAmD;IACnD,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9D,qDAAqD;IACrD,iBAAiB,EAAE,CAAC,KAAK,EAAE;QACzB,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;QACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;KACnB,KAAK,SAAS,CAAA;IACf,sCAAsC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kCAAkC;IAClC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,kCAAkC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,wCAAwC;IACxC,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AASD,wBAAgB,YAAY,CAAC,EAC3B,QAAQ,EACR,QAAQ,EACR,iBAAiB,EACjB,SAAS,EACT,QAAgB,EAChB,KAAwB,EACxB,WAAW,GACZ,EAAE,iBAAiB,+BA8QnB"}
1
+ {"version":3,"file":"BrandingCard.d.ts","sourceRoot":"","sources":["../../src/branding/BrandingCard.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAGhD,MAAM,WAAW,iBAAiB;IAChC,qCAAqC;IACrC,QAAQ,EAAE;QACR,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,cAAc,CAAC,EAAE,MAAM,CAAA;QACvB,aAAa,CAAC,EAAE,MAAM,CAAA;QACtB,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,YAAY,CAAC,EAAE,MAAM,CAAA;QACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAC3B,CAAA;IACD,mDAAmD;IACnD,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9D,qDAAqD;IACrD,iBAAiB,EAAE,CAAC,KAAK,EAAE;QACzB,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;QACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;KACnB,KAAK,SAAS,CAAA;IACf,sCAAsC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kCAAkC;IAClC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,kCAAkC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,wCAAwC;IACxC,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AASD,wBAAgB,YAAY,CAAC,EAC3B,QAAQ,EACR,QAAQ,EACR,iBAAiB,EACjB,SAAS,EACT,QAAgB,EAChB,KAAwB,EACxB,WAAW,GACZ,EAAE,iBAAiB,+BA8QnB"}
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
3
  /**
3
4
  * BrandingCard — A card component for displaying and editing branding settings
@@ -1 +1 @@
1
- {"version":3,"file":"ColorEditorSheet.d.ts","sourceRoot":"","sources":["../../src/branding/ColorEditorSheet.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAuB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAG3D,MAAM,WAAW,qBAAqB;IACpC,gCAAgC;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,uCAAuC;IACvC,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,0BAA0B;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,oCAAoC;IACpC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,qDAAqD;IACrD,YAAY,EAAE,CAAC,KAAK,EAAE;QACpB,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;QACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;KACnB,KAAK,SAAS,CAAA;IACf,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,IAAI,EACJ,YAAY,EACZ,KAAoB,EACpB,WAAW,EACX,KAAK,EACL,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,SAAS,EACT,QAAgB,GACjB,EAAE,qBAAqB,sCAuIvB"}
1
+ {"version":3,"file":"ColorEditorSheet.d.ts","sourceRoot":"","sources":["../../src/branding/ColorEditorSheet.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAuB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAG3D,MAAM,WAAW,qBAAqB;IACpC,gCAAgC;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,uCAAuC;IACvC,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,0BAA0B;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,oCAAoC;IACpC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,qDAAqD;IACrD,YAAY,EAAE,CAAC,KAAK,EAAE;QACpB,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;QACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;KACnB,KAAK,SAAS,CAAA;IACf,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,IAAI,EACJ,YAAY,EACZ,KAAoB,EACpB,WAAW,EACX,KAAK,EACL,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,SAAS,EACT,QAAgB,GACjB,EAAE,qBAAqB,sCAuIvB"}
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /**
3
4
  * ColorEditorSheet — A sheet/dialog for editing branding colors
@@ -1 +1 @@
1
- {"version":3,"file":"OklchColorPicker.d.ts","sourceRoot":"","sources":["../../src/branding/OklchColorPicker.tsx"],"names":[],"mappings":"AAyBA,MAAM,WAAW,qBAAqB;IACpC,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,kCAAkC;IAClC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AA+BD,wBAAgB,gBAAgB,CAAC,EAC/B,KAAkC,EAClC,QAAQ,EACR,KAAK,EACL,QAAgB,EAChB,SAAS,EACT,WAAW,EACX,KAAK,GACN,EAAE,qBAAqB,+BA8LvB"}
1
+ {"version":3,"file":"OklchColorPicker.d.ts","sourceRoot":"","sources":["../../src/branding/OklchColorPicker.tsx"],"names":[],"mappings":"AA2BA,MAAM,WAAW,qBAAqB;IACpC,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,kCAAkC;IAClC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AA+BD,wBAAgB,gBAAgB,CAAC,EAC/B,KAAkC,EAClC,QAAQ,EACR,KAAK,EACL,QAAgB,EAChB,SAAS,EACT,WAAW,EACX,KAAK,GACN,EAAE,qBAAqB,+BA8LvB"}
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  /**
3
4
  * OklchColorPicker — A color input component for OKLCH color format
@@ -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":"AAEA,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,29 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Command as CommandPrimitive } from 'cmdk';
4
+ import { Search } from 'lucide-react';
5
+ import * as React from 'react';
6
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog.js';
7
+ import { cn } from './utils.js';
8
+ 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 })));
9
+ Command.displayName = CommandPrimitive.displayName;
10
+ const CommandDialog = ({ children, title = 'Command palette', description = 'Search for commands, navigation, and actions', ...props }) => {
11
+ 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 })] }) }));
12
+ };
13
+ 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 })] })));
14
+ CommandInput.displayName = CommandPrimitive.Input.displayName;
15
+ 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 })));
16
+ CommandList.displayName = CommandPrimitive.List.displayName;
17
+ const CommandEmpty = React.forwardRef((props, ref) => (_jsx(CommandPrimitive.Empty, { ref: ref, className: "py-6 text-center text-sm", ...props })));
18
+ CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
19
+ 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 })));
20
+ CommandGroup.displayName = CommandPrimitive.Group.displayName;
21
+ const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Separator, { ref: ref, className: cn('-mx-1 h-px bg-border', className), ...props })));
22
+ CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
23
+ 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 })));
24
+ CommandItem.displayName = CommandPrimitive.Item.displayName;
25
+ const CommandShortcut = ({ className, ...props }) => {
26
+ return (_jsx("span", { className: cn('ml-auto text-xs tracking-widest text-muted-foreground', className), ...props }));
27
+ };
28
+ CommandShortcut.displayName = 'CommandShortcut';
29
+ export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, };
@@ -1 +1 @@
1
- {"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["../src/dialog.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,wBAAwB,EAAqB,KAAK,cAAc,EAAE,MAAM,OAAO,CAAA;AACzG,OAAO,KAAK,eAAe,MAAM,wBAAwB,CAAA;AAGzD,eAAO,MAAM,MAAM,iDAAuB,CAAA;AAC1C,eAAO,MAAM,aAAa,kIAA0B,CAAA;AACpD,eAAO,MAAM,WAAW,gIAAwB,CAAA;AAChD,eAAO,MAAM,YAAY,uDAAyB,CAAA;AAElD,MAAM,MAAM,kBAAkB,GAAG,wBAAwB,CAAC,OAAO,eAAe,CAAC,OAAO,CAAC,CAAA;AACzF,MAAM,MAAM,kBAAkB,GAAG,wBAAwB,CAAC,OAAO,eAAe,CAAC,OAAO,CAAC,CAAA;AAEzF,eAAO,MAAM,aAAa,4LAexB,CAAA;AAEF,eAAO,MAAM,aAAa,4LA8BxB,CAAA;AAEF,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,EAAE,cAAc,CAAC,cAAc,CAAC,+BAEnF;AAED,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,EAAE,cAAc,CAAC,cAAc,CAAC,+BAOnF;AAED,eAAO,MAAM,WAAW,kMAWtB,CAAA;AAEF,eAAO,MAAM,iBAAiB,4MAW5B,CAAA"}
1
+ {"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["../src/dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,wBAAwB,EAAqB,KAAK,cAAc,EAAE,MAAM,OAAO,CAAA;AACzG,OAAO,KAAK,eAAe,MAAM,wBAAwB,CAAA;AAGzD,eAAO,MAAM,MAAM,iDAAuB,CAAA;AAC1C,eAAO,MAAM,aAAa,kIAA0B,CAAA;AACpD,eAAO,MAAM,WAAW,gIAAwB,CAAA;AAChD,eAAO,MAAM,YAAY,uDAAyB,CAAA;AAElD,MAAM,MAAM,kBAAkB,GAAG,wBAAwB,CAAC,OAAO,eAAe,CAAC,OAAO,CAAC,CAAA;AACzF,MAAM,MAAM,kBAAkB,GAAG,wBAAwB,CAAC,OAAO,eAAe,CAAC,OAAO,CAAC,CAAA;AAEzF,eAAO,MAAM,aAAa,4LAexB,CAAA;AAEF,eAAO,MAAM,aAAa,4LA8BxB,CAAA;AAEF,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,EAAE,cAAc,CAAC,cAAc,CAAC,+BAEnF;AAED,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,EAAE,cAAc,CAAC,cAAc,CAAC,+BAOnF;AAED,eAAO,MAAM,WAAW,kMAWtB,CAAA;AAEF,eAAO,MAAM,iBAAiB,4MAW5B,CAAA"}
package/dist/dialog.js CHANGED
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { forwardRef } from 'react';
3
4
  import * as DialogPrimitive from '@radix-ui/react-dialog';
@@ -0,0 +1,269 @@
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
+ /**
170
+ * Column used to ORDER the search results before `limit` truncates them.
171
+ *
172
+ * A `.limit()` with no `.order()` returns an ARBITRARY subset of the matches —
173
+ * Postgres hands back whatever the scan reaches first. Once a table holds more
174
+ * rows matching a search term than `limit`, the row the user is looking for
175
+ * (typically the one they just created) silently falls outside the window and
176
+ * the picker claims it does not exist. Measured in crm7: 366 clients matched
177
+ * `%Acme%` against a limit of 50.
178
+ *
179
+ * Defaults to `created_at` descending so the newest match is always offered.
180
+ * A table WITHOUT that column must pass its own — but an unknown column fails
181
+ * SOFT (see fetchItems), because `table` is dynamic at some call sites.
182
+ */
183
+ orderBy?: string;
184
+ /** Sort direction for {@link orderBy}. Defaults to descending (newest first). */
185
+ orderAscending?: boolean;
186
+ /** Allow clearing the selection */
187
+ clearable?: boolean;
188
+ /** Additional className for the trigger button */
189
+ className?: string;
190
+ /** Label shown in the trigger when an entity is selected */
191
+ selectedLabel?: string;
192
+ /**
193
+ * Notifies the parent when the selector's query state changes so the
194
+ * parent form can gate submission ("Resolving company…") while the
195
+ * typeahead is in-flight. Fires on every loading-state transition plus
196
+ * when the query text changes.
197
+ */
198
+ onQueryStateChange?: (state: {
199
+ isLoading: boolean;
200
+ query: string;
201
+ }) => void;
202
+ /**
203
+ * Optional custom row renderer. When provided, replaces the default
204
+ * display/secondary field layout for each result row, letting selectors
205
+ * visually distinguish inactive/discontinued rows.
206
+ *
207
+ * The renderer receives the row plus a selection indicator so custom
208
+ * implementations can still draw a check/highlight for the currently
209
+ * selected entity. The wrapping `<CommandItem>` (with its own click /
210
+ * keyboard handler) is supplied by EntitySelector — `renderRow` only
211
+ * renders the row's inner content.
212
+ */
213
+ renderRow?: (row: T, meta: {
214
+ isSelected: boolean;
215
+ }) => ReactNode;
216
+ /** Form control id for aria-labelledby */
217
+ id?: ComponentPropsWithoutRef<'button'>['id'];
218
+ /** Form control name attribute */
219
+ name?: ComponentPropsWithoutRef<'button'>['name'];
220
+ /** Form control aria-describedby */
221
+ 'aria-describedby'?: ComponentPropsWithoutRef<'button'>['aria-describedby'];
222
+ /** Form control aria-invalid */
223
+ 'aria-invalid'?: ComponentPropsWithoutRef<'button'>['aria-invalid'];
224
+ /** Form control aria-labelledby */
225
+ 'aria-labelledby'?: ComponentPropsWithoutRef<'button'>['aria-labelledby'];
226
+ /**
227
+ * Import-on-select: when a selector SEARCHES one table/schema but `value`
228
+ * is a foreign key into a DIFFERENT table (e.g. a catalogue selector
229
+ * searches `catalog.qualifications` but the owning column — and
230
+ * therefore `value` — points at `public.qualifications`, the tenant's
231
+ * own copy created on select), these three let the "resolve an
232
+ * already-selected value" lookup target that different table instead of
233
+ * `table`/`schema`/`selectColumns`. Default (all three omitted) is
234
+ * today's behaviour: resolve from the same place search runs against —
235
+ * every existing caller is unaffected.
236
+ */
237
+ resolveValueTable?: string;
238
+ resolveValueSchema?: string;
239
+ resolveValueSelectColumns?: string;
240
+ /**
241
+ * ONE-SHOT: ids already linked to this record through an earlier screen.
242
+ *
243
+ * DRY is about one row in one table. This is about the USER never typing
244
+ * the same thing twice: the second screen in a workflow surfaces what the
245
+ * first screen already established, so the answer is one click rather
246
+ * than a search.
247
+ *
248
+ * These are fetched BY ID and shown in their own group at the top, before
249
+ * any typing. That matters — `minChars` means an ordinary picker shows
250
+ * nothing at all until the user types, which is exactly the moment they
251
+ * are being asked to re-derive something the system already knows.
252
+ * Suggested rows bypass that.
253
+ *
254
+ * This narrows nothing. The full catalogue stays searchable underneath,
255
+ * so a genuinely new choice is never blocked — the same
256
+ * advise-don't-block posture the rest of this domain runs on.
257
+ */
258
+ suggestedIds?: readonly string[];
259
+ /**
260
+ * Heading for the suggested group. Say WHERE the options came from — "From
261
+ * Teagan Taylor's record", "Preferred by Built Management Services" —
262
+ * because a bare "Suggested" gives the user no way to judge whether to
263
+ * trust it.
264
+ */
265
+ suggestedLabel?: string;
266
+ }
267
+ 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, orderBy, orderAscending, 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;
268
+ export default EntitySelector;
269
+ //# 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,330 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Check, ChevronsUpDown, Loader2, Plus, X } from 'lucide-react';
4
+ import { useCallback, useEffect, useRef, useState } from 'react';
5
+ import { Button } from './button.js';
6
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from './command.js';
7
+ import { Popover, PopoverContent, PopoverTrigger } from './popover.js';
8
+ import { StatusBadge } from './status-badge.js';
9
+ import { cn } from './utils.js';
10
+ // ============================================================================
11
+ // COMPONENT
12
+ // ============================================================================
13
+ 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, orderBy = 'created_at', orderAscending = false, clearable = true, className, selectedLabel, onQueryStateChange, renderRow, id, name, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-labelledby': ariaLabelledBy, resolveValueTable, resolveValueSchema, resolveValueSelectColumns, suggestedIds, suggestedLabel = 'Already linked', }) {
14
+ const [open, setOpen] = useState(false);
15
+ const [query, setQuery] = useState('');
16
+ const [items, setItems] = useState([]);
17
+ const [suggestedItems, setSuggestedItems] = useState([]);
18
+ const [isLoading, setIsLoading] = useState(false);
19
+ const [selectedItem, setSelectedItem] = useState(null);
20
+ /**
21
+ * A failed search (bad `selectColumns` embed, RLS denial, network error,
22
+ * etc.) must not fall through to `setItems([])` with only a logger
23
+ * side-channel — the rendered dropdown would show the exact same
24
+ * `CommandEmpty` "No items found" copy as a genuine zero-result search,
25
+ * which is indistinguishable from the user's perspective. Tracking the
26
+ * error message separately lets the render below show a distinct,
27
+ * `role="alert"` state instead.
28
+ */
29
+ const [error, setError] = useState(null);
30
+ const debounceRef = useRef(null);
31
+ const hasLoadedInitial = useRef(false);
32
+ /**
33
+ * Returns a schema-scoped `.from(table)` builder. Defaults to the public
34
+ * schema (identical to the pre-existing `supabaseClient.from(table)`
35
+ * behaviour) unless `schema` is supplied, in which case it queries via
36
+ * `supabaseClient.schema(schema).from(table)` — see the `schema` prop doc.
37
+ *
38
+ * {@link EntitySelectorSupabaseClient} is structurally typed for the same
39
+ * reason {@link EntitySelectorQuery} is `any`-erased: this package has no
40
+ * generated `Database` type to parameterise against, and each consumer's
41
+ * own generated types differ. `.schema()` is likewise unparameterised
42
+ * here — a schema-agnostic escape hatch, not a typing gap introduced by
43
+ * this component.
44
+ */
45
+ const fromTable = useCallback(() => {
46
+ if (schema) {
47
+ return supabaseClient.schema(schema).from(table);
48
+ }
49
+ return supabaseClient.from(table);
50
+ }, [schema, table, supabaseClient]);
51
+ /**
52
+ * Table/schema/columns for resolving an already-selected `value` into a
53
+ * display row. Defaults to the search table when `resolveValueTable` is
54
+ * omitted — see the prop doc for why these can differ.
55
+ */
56
+ const resolveTableName = resolveValueTable ?? table;
57
+ const resolveSchemaName = resolveValueSchema ?? schema;
58
+ const resolveSelectColumns = resolveValueSelectColumns ?? selectColumns;
59
+ const fromResolveTable = useCallback(() => {
60
+ if (resolveSchemaName) {
61
+ return supabaseClient.schema(resolveSchemaName).from(resolveTableName);
62
+ }
63
+ return supabaseClient.from(resolveTableName);
64
+ }, [resolveSchemaName, resolveTableName, supabaseClient]);
65
+ // ── Resolve selected item from value prop ──────────────────────────────
66
+ useEffect(() => {
67
+ if (!value) {
68
+ setSelectedItem(null);
69
+ return;
70
+ }
71
+ // If we already have this item loaded, use it — but only when the
72
+ // search results and the resolve target are the SAME table (the
73
+ // `resolveValueTable` override case means `items` come from a
74
+ // different table than `value` points at, so a matching `id` there
75
+ // would be coincidental, not correct).
76
+ if (!resolveValueTable) {
77
+ const existing = items.find((i) => String(i.id) === String(value));
78
+ if (existing) {
79
+ setSelectedItem(existing);
80
+ return;
81
+ }
82
+ }
83
+ // Otherwise fetch the single entity by ID
84
+ let cancelled = false;
85
+ (async () => {
86
+ const { data } = await fromResolveTable()
87
+ .select(resolveSelectColumns)
88
+ .eq('id', value)
89
+ .single();
90
+ if (!cancelled && data) {
91
+ setSelectedItem(data);
92
+ }
93
+ })();
94
+ return () => {
95
+ cancelled = true;
96
+ };
97
+ }, [value, resolveValueTable, resolveTableName, resolveSelectColumns, fromResolveTable]); // eslint-disable-line react-hooks/exhaustive-deps
98
+ // ── Search / load items ────────────────────────────────────────────────
99
+ const fetchItems = useCallback(async (searchTerm) => {
100
+ setIsLoading(true);
101
+ setError(null);
102
+ try {
103
+ // Supabase `.select()` returns a narrow `PostgrestTransformBuilder`
104
+ // subtype that carries the concrete row schema. We widen it to the
105
+ // schema-agnostic {@link EntitySelectorQuery} alias so the public
106
+ // `filterFn` prop type (which is unparameterised by design) can
107
+ // consume and return it without callers needing `as` casts. The
108
+ // single widen-cast here replaces the casts every inner selector
109
+ // would otherwise need.
110
+ // A Supabase query builder is CONSUMED once awaited, so the query is built
111
+ // by a factory: the ordered attempt and the unordered fallback below each
112
+ // need their own instance.
113
+ const build = (withOrder) => {
114
+ // Supabase `.select()` returns a narrow `PostgrestTransformBuilder`
115
+ // subtype carrying the concrete row schema. We widen it to the
116
+ // schema-agnostic {@link EntitySelectorQuery} alias so the public
117
+ // `filterFn` prop type (unparameterised by design) can consume and
118
+ // return it without callers needing `as` casts.
119
+ let q = fromTable().select(selectColumns);
120
+ // Apply custom filters
121
+ if (filterFn) {
122
+ q = filterFn(q);
123
+ }
124
+ // Apply search if term is long enough
125
+ if (searchTerm.length >= Math.max(minChars, 1)) {
126
+ const escaped = searchTerm
127
+ .replace(/\\/g, '\\\\')
128
+ .replace(/"/g, '\\"')
129
+ .replace(/,/g, '\\,')
130
+ .replace(/\(/g, '\\(')
131
+ .replace(/\)/g, '\\)');
132
+ const term = `%${escaped}%`;
133
+ const orConditions = searchColumns.map((col) => `${col}.ilike.${term}`).join(',');
134
+ q = q.or(orConditions);
135
+ }
136
+ // ORDER BEFORE LIMIT. Without this the picker returns an arbitrary
137
+ // `limit`-sized slice of the matches, so a freshly created row is
138
+ // routinely absent from its own results.
139
+ if (withOrder && orderBy) {
140
+ q = q.order(orderBy, { ascending: orderAscending });
141
+ }
142
+ return q.limit(limit);
143
+ };
144
+ let { data, error: queryError } = await build(true);
145
+ // Fail SOFT on an unknown order column (Postgres 42703 / PostgREST
146
+ // PGRST204). `table` is dynamic at some call sites — a page-builder
147
+ // relationship widget or an admin data browser passes a table name from a
148
+ // registry — so the default `created_at` is not guaranteed to exist.
149
+ // Degrading to the previous unordered behaviour keeps those callers
150
+ // working; the ordering is an improvement, not a precondition.
151
+ if (queryError && orderBy) {
152
+ const code = queryError.code;
153
+ const msg = queryError.message ?? '';
154
+ if (code === '42703' || code === 'PGRST204' || msg.includes(orderBy)) {
155
+ logger.warn(`EntitySelector(${table}): order column "${orderBy}" is not present; ` +
156
+ `retrying UNORDERED. Results may omit recently created rows — pass an ` +
157
+ `explicit orderBy for this table to restore ordering.`);
158
+ ({ data, error: queryError } = await build(false));
159
+ }
160
+ }
161
+ if (queryError) {
162
+ logger.warn(`EntitySelector(${table}): search error`, queryError.message);
163
+ setError(queryError.message || 'Failed to load results.');
164
+ setItems([]);
165
+ return;
166
+ }
167
+ setItems(data ?? []);
168
+ }
169
+ catch (err) {
170
+ logger.error(`EntitySelector(${table}): unexpected error`, err);
171
+ setError(err instanceof Error ? err.message : 'Failed to load results.');
172
+ setItems([]);
173
+ }
174
+ finally {
175
+ setIsLoading(false);
176
+ }
177
+ }, [table, selectColumns, searchColumns, filterFn, minChars, limit, fromTable, logger]);
178
+ /**
179
+ * ONE-SHOT: fetch the already-linked rows BY ID.
180
+ *
181
+ * Deliberately a separate query from the search, for two reasons. It must not
182
+ * be subject to `minChars` — the whole point is that the user sees the answer
183
+ * without typing — and it must not be subject to `limit`, or a suggestion
184
+ * could fall off the end of a busy catalogue and silently stop being offered.
185
+ *
186
+ * `filterFn` is NOT applied. A suggestion is already an established link made
187
+ * by this user on an earlier screen; re-filtering it here could hide a row the
188
+ * system itself put there, which reads as the feature being broken.
189
+ */
190
+ const suggestedKey = suggestedIds?.length ? [...suggestedIds].sort().join(',') : '';
191
+ useEffect(() => {
192
+ if (!open || !suggestedKey) {
193
+ if (!suggestedKey)
194
+ setSuggestedItems([]);
195
+ return;
196
+ }
197
+ let cancelled = false;
198
+ void (async () => {
199
+ try {
200
+ const { data, error: sErr } = await fromTable()
201
+ .select(selectColumns)
202
+ .in('id', suggestedKey.split(','));
203
+ if (cancelled)
204
+ return;
205
+ if (sErr) {
206
+ // Never surface this as a page error — the catalogue below still
207
+ // works, so a failed suggestion degrades to an ordinary search
208
+ // rather than blocking the user.
209
+ logger.warn(`EntitySelector(${table}): suggested fetch failed`, sErr.message);
210
+ setSuggestedItems([]);
211
+ return;
212
+ }
213
+ setSuggestedItems(data ?? []);
214
+ }
215
+ catch (err) {
216
+ if (!cancelled) {
217
+ logger.warn(`EntitySelector(${table}): suggested fetch threw`, err);
218
+ setSuggestedItems([]);
219
+ }
220
+ }
221
+ })();
222
+ return () => {
223
+ cancelled = true;
224
+ };
225
+ }, [open, suggestedKey, fromTable, selectColumns, table, logger]);
226
+ /**
227
+ * One row, shared by the suggested group and the catalogue group.
228
+ *
229
+ * Extracted rather than duplicated: the two groups must stay visually and
230
+ * behaviourally identical, and a copy is how one of them quietly loses the
231
+ * selected-check or the `renderRow` override later.
232
+ */
233
+ const renderItem = (item) => {
234
+ const itemId = String(item.id);
235
+ const isSelected = String(value) === itemId;
236
+ 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));
237
+ };
238
+ // ── Load items when popover opens (for small datasets / minChars=0) ───
239
+ useEffect(() => {
240
+ if (open && !hasLoadedInitial.current) {
241
+ hasLoadedInitial.current = true;
242
+ fetchItems('');
243
+ }
244
+ }, [open, fetchItems]);
245
+ // ── Debounced search on query change ───────────────────────────────────
246
+ const handleQueryChange = useCallback((val) => {
247
+ setQuery(val);
248
+ if (debounceRef.current)
249
+ clearTimeout(debounceRef.current);
250
+ debounceRef.current = setTimeout(() => fetchItems(val), debounceMs);
251
+ }, [fetchItems, debounceMs]);
252
+ // ── Surface loading+query state upward so the parent form can gate submit ─
253
+ useEffect(() => {
254
+ if (onQueryStateChange) {
255
+ onQueryStateChange({ isLoading, query });
256
+ }
257
+ }, [isLoading, query, onQueryStateChange]);
258
+ // Cleanup debounce on unmount
259
+ useEffect(() => {
260
+ return () => {
261
+ if (debounceRef.current)
262
+ clearTimeout(debounceRef.current);
263
+ };
264
+ }, []);
265
+ // ── Selection handlers ─────────────────────────────────────────────────
266
+ /**
267
+ * A PRIOR version of this fix had an import-on-select consumer call the
268
+ * parent's `onSelect(null)` on every import-RPC failure, meant to clear a
269
+ * stale optimistic display. That made things worse: in EDIT mode `value`
270
+ * starts as the SAVED entity id, and `onSelect(null)` overwrote the
271
+ * parent form's state with `undefined` — wiping a link the database
272
+ * actually has, the exact inverse of the bug being fixed, on every failed
273
+ * re-pick.
274
+ *
275
+ * The real defect is here, not in the caller: this function set
276
+ * `selectedItem` SYNCHRONOUSLY AND OPTIMISTICALLY on every click, before
277
+ * `onSelect` (which may be async — see the prop doc) had any chance to
278
+ * fail. For a synchronous `onSelect` (the overwhelming majority of
279
+ * consumers) that is correct — there is no failure mode to protect
280
+ * against. For an async `onSelect` (routing the pick through an
281
+ * import-from-catalogue RPC, say), setting `selectedItem` before the
282
+ * round-trip resolves means: on failure, the trigger shows a row the
283
+ * database was never given, and it stays wrong because `value` never
284
+ * changed, so the "resolve selected item from value" effect (above) has
285
+ * nothing to react to and never corrects it.
286
+ *
287
+ * Fix: detect a thenable return and skip the optimistic update entirely.
288
+ * The eventual state is then driven ONLY by `value`:
289
+ * - success: the async `onSelect` calls the caller's real setter with
290
+ * the new (imported) id, `value` changes, the resolve-effect fetches
291
+ * and displays it — one extra network round trip, correct result.
292
+ * - failure: the async `onSelect` does NOT call the caller's setter,
293
+ * `value` is untouched, `selectedItem` was never touched either — a
294
+ * fresh form keeps showing the placeholder, an edit form keeps
295
+ * showing the saved entity. Neither a false assertion nor a false
296
+ * wipe.
297
+ */
298
+ const handleItemSelect = (item) => {
299
+ const result = onSelect(item);
300
+ if (!result || typeof result.then !== 'function') {
301
+ setSelectedItem(item);
302
+ }
303
+ setOpen(false);
304
+ setQuery('');
305
+ };
306
+ const handleClear = (e) => {
307
+ e.stopPropagation();
308
+ setSelectedItem(null);
309
+ onSelect(null);
310
+ };
311
+ // ── Render ─────────────────────────────────────────────────────────────
312
+ const triggerLabel = selectedItem ? (selectedLabel ?? displayField(selectedItem)) : placeholder;
313
+ 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)) })), (() => {
314
+ // De-dupe so a suggested row is not offered twice; showing
315
+ // the same entity in both groups reads as two different
316
+ // records.
317
+ const shown = suggestedItems.length > 0 && query.length === 0
318
+ ? items.filter((i) => !suggestedItems.some((s) => String(s.id) === String(i.id)))
319
+ : items;
320
+ if (shown.length === 0)
321
+ return null;
322
+ return (_jsx(CommandGroup, { heading: suggestedItems.length > 0 && query.length === 0
323
+ ? 'All options'
324
+ : undefined, children: shown.map((item) => renderItem(item)) }));
325
+ })()] })), onQuickAdd && (_jsx("div", { className: "border-t p-1", children: _jsxs(Button, { variant: "ghost", size: "sm", className: "w-full justify-start text-muted-foreground", onClick: () => {
326
+ setOpen(false);
327
+ onQuickAdd();
328
+ }, 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] }))] }));
329
+ }
330
+ export default EntitySelector;
@@ -1 +1 @@
1
- {"version":3,"file":"error-boundary.d.ts","sourceRoot":"","sources":["../src/error-boundary.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAEL,KAAK,4BAA4B,EAElC,MAAM,sBAAsB,CAAA;AAI7B,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,SAAS,CAAA;IACnB,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,cAAc,CAAC,EAAE,4BAA4B,CAAC,gBAAgB,CAAC,CAAA;IAC/D,OAAO,CAAC,EAAE,4BAA4B,CAAC,SAAS,CAAC,CAAA;IACjD,OAAO,CAAC,EAAE,4BAA4B,CAAC,SAAS,CAAC,CAAA;IACjD,SAAS,CAAC,EAAE,4BAA4B,CAAC,WAAW,CAAC,CAAA;IACrD,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B;AA0BD,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,QAAQ,EACR,cAAc,EACd,OAAO,EACP,OAAO,EACP,SAAS,EACT,KAAK,EACL,WAAW,EACX,gBAAgB,GACjB,EAAE,kBAAkB,+BA2CpB"}
1
+ {"version":3,"file":"error-boundary.d.ts","sourceRoot":"","sources":["../src/error-boundary.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAEL,KAAK,4BAA4B,EAElC,MAAM,sBAAsB,CAAA;AAI7B,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,SAAS,CAAA;IACnB,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,cAAc,CAAC,EAAE,4BAA4B,CAAC,gBAAgB,CAAC,CAAA;IAC/D,OAAO,CAAC,EAAE,4BAA4B,CAAC,SAAS,CAAC,CAAA;IACjD,OAAO,CAAC,EAAE,4BAA4B,CAAC,SAAS,CAAC,CAAA;IACjD,SAAS,CAAC,EAAE,4BAA4B,CAAC,WAAW,CAAC,CAAA;IACrD,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B;AA0BD,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,QAAQ,EACR,cAAc,EACd,OAAO,EACP,OAAO,EACP,SAAS,EACT,KAAK,EACL,WAAW,EACX,gBAAgB,GACjB,EAAE,kBAAkB,+BA2CpB"}
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
2
3
  import { ErrorBoundary as ReactErrorBoundary, } from 'react-error-boundary';
3
4
  import { Button } from './button.js';
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":"AAEA,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,10 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as React from 'react';
4
+ import * as PopoverPrimitive from '@radix-ui/react-popover';
5
+ import { cn } from './utils.js';
6
+ const Popover = PopoverPrimitive.Root;
7
+ const PopoverTrigger = PopoverPrimitive.Trigger;
8
+ 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 }) })));
9
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
10
+ 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.2",
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
- "@bsuite/theme": ">=0.7.0",
101
+ "@bsuite/theme": ">=0.7.0 <2",
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
- "@bsuite/theme": "^0.11.0",
115
+ "@bsuite/theme": "workspace:^",
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",