@bsuite/ui 1.0.3 → 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,98 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+ /**
3
+ * DataUnavailable — the honest state.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * Five live surfaces across crm7 and business-suite-unified shared one idiom:
8
+ *
9
+ * const rows = query.data ?? HARDCODED_FALLBACK
10
+ *
11
+ * That line turns every failure into a confident-looking answer. The worst
12
+ * instance rendered a fabricated 95% compliance score — including "Fair Work
13
+ * Compliance — Award rates verified" — when the underlying query was denied by
14
+ * RLS. An authorization failure was presented to the user as a compliance
15
+ * assurance. Another exported invented dollar figures to a file that then looks
16
+ * authoritative to whoever opens it.
17
+ *
18
+ * The operator's standing rule is: never display mock data in the UI,
19
+ * especially for financial or account-related information.
20
+ *
21
+ * THE CONTRACT
22
+ * ------------
23
+ * This component renders the ABSENCE of data. It therefore:
24
+ *
25
+ * 1. Takes no numeric prop and renders no number of its own. There is
26
+ * nothing here that can be misread as a measurement.
27
+ * 2. Never uses the success tone. A failure is never green.
28
+ * 3. Separates "nothing exists" from "we could not find out", and separates
29
+ * "you may not see this" from "this broke". Collapsing those is precisely
30
+ * the defect it replaces.
31
+ * 4. Surfaces the real reason verbatim, in a monospace diagnostic block, so
32
+ * the reader can tell a permission denial from a network fault.
33
+ * 5. Carries `data-slot="data-unavailable"` and `data-state`, so tests and
34
+ * lints can assert that a surface degraded honestly.
35
+ *
36
+ * Pair it with `resolveDataState` so the call site never re-derives the
37
+ * branching, and the `??` idiom has nowhere to come back.
38
+ */
39
+ export type DataUnavailableState =
40
+ /** The query is in flight. Nothing is known yet. */
41
+ 'loading'
42
+ /** The query succeeded and the answer is genuinely "none". */
43
+ | 'empty'
44
+ /** The query failed. We do not know the answer. */
45
+ | 'error'
46
+ /** The query was refused. The data may exist; this caller may not see it. */
47
+ | 'forbidden'
48
+ /** No queryable source is reachable from this surface at all. */
49
+ | 'unavailable';
50
+ export interface DataUnavailableProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
51
+ state: DataUnavailableState;
52
+ /**
53
+ * What could not be shown, as a specific noun — "Budgets", "Compliance
54
+ * checks", "OAuth clients". Overrides the generic preset heading.
55
+ */
56
+ title?: ReactNode;
57
+ description?: ReactNode;
58
+ /**
59
+ * The real, verbatim failure reason — an error message, a Postgres code.
60
+ * Rendered as monospace diagnostics, never as prose. Pass the actual thing;
61
+ * a sanitised reason is how "unknown" becomes "fine" again.
62
+ */
63
+ reason?: ReactNode;
64
+ /** Retry handler. Omit when retrying cannot help (`forbidden`). */
65
+ onRetry?: () => void;
66
+ retryLabel?: string;
67
+ /** Escape hatch for a site-specific action (e.g. "Request access"). */
68
+ action?: ReactNode;
69
+ /** `compact` suits an inline card slot; `block` a full page region. */
70
+ size?: 'compact' | 'block';
71
+ }
72
+ export declare function DataUnavailable({ state, title, description, reason, onRetry, retryLabel, action, size, className, ...props }: DataUnavailableProps): import("react").JSX.Element;
73
+ /**
74
+ * Structural subset of a TanStack Query result. Declared structurally on
75
+ * purpose: `@bsuite/ui` must not take a dependency on the data layer, and this
76
+ * shape is equally satisfiable by a hand-rolled hook.
77
+ */
78
+ export interface QueryLike<T> {
79
+ data: T | undefined;
80
+ isPending?: boolean;
81
+ isLoading?: boolean;
82
+ isError?: boolean;
83
+ error?: unknown;
84
+ }
85
+ /** Human-readable reason string for the `reason` prop. */
86
+ export declare function describeError(error: unknown): string;
87
+ /**
88
+ * Map a query result to either `'ready'` (render the real data) or the honest
89
+ * state to render instead.
90
+ *
91
+ * The point is that there is no fourth answer. A call site cannot get a
92
+ * fallback out of this function, which is what makes the class fix stick.
93
+ *
94
+ * @param isEmpty how to decide the data is genuinely empty. Defaults to
95
+ * "an array with no elements". Pass explicitly for object-shaped payloads.
96
+ */
97
+ export declare function resolveDataState<T>(query: QueryLike<T>, isEmpty?: (data: T) => boolean): DataUnavailableState | 'ready';
98
+ //# sourceMappingURL=data-unavailable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-unavailable.d.ts","sourceRoot":"","sources":["../src/data-unavailable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAMH,MAAM,MAAM,oBAAoB;AAC9B,oDAAoD;AAClD,SAAS;AACX,8DAA8D;GAC5D,OAAO;AACT,mDAAmD;GACjD,OAAO;AACT,6EAA6E;GAC3E,WAAW;AACb,iEAAiE;GAC/D,aAAa,CAAA;AA0JjB,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzF,KAAK,EAAE,oBAAoB,CAAA;IAC3B;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,uEAAuE;IACvE,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,uEAAuE;IACvE,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,CAAA;CAC3B;AAED,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,KAAK,EACL,WAAW,EACX,MAAM,EACN,OAAO,EACP,UAAwB,EACxB,MAAM,EACN,IAAc,EACd,SAAS,EACT,GAAG,KAAK,EACT,EAAE,oBAAoB,+BAiEtB;AAMD;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,CAAC;IAC1B,IAAI,EAAE,CAAC,GAAG,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAwBD,0DAA0D;AAC1D,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAcpD;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,EACnB,OAAO,GAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAmD,GACxE,oBAAoB,GAAG,OAAO,CAKhC"}
@@ -0,0 +1,162 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from './utils.js';
3
+ const PRESETS = {
4
+ loading: {
5
+ tone: 'info',
6
+ title: 'Loading…',
7
+ description: 'Fetching the current figures.',
8
+ icon: 'spinner',
9
+ live: 'polite',
10
+ role: 'status',
11
+ },
12
+ empty: {
13
+ tone: 'neutral',
14
+ title: 'Nothing recorded yet',
15
+ description: 'This is a real answer, not a failure — no records exist for the current selection.',
16
+ icon: 'empty',
17
+ live: 'polite',
18
+ role: 'status',
19
+ },
20
+ error: {
21
+ tone: 'destructive',
22
+ title: 'Could not load this',
23
+ description: 'The request failed, so nothing is shown. This is not an empty result — the real value is unknown.',
24
+ icon: 'alert',
25
+ live: 'assertive',
26
+ role: 'alert',
27
+ },
28
+ forbidden: {
29
+ tone: 'warning',
30
+ title: 'You do not have access to this',
31
+ description: 'The request was refused. Data may exist that this account is not permitted to read, so nothing is shown.',
32
+ icon: 'lock',
33
+ live: 'assertive',
34
+ role: 'alert',
35
+ },
36
+ unavailable: {
37
+ tone: 'warning',
38
+ title: 'Not readable from here',
39
+ description: 'No queryable source for this information is reachable from this surface, so nothing is shown.',
40
+ icon: 'unplugged',
41
+ live: 'polite',
42
+ role: 'status',
43
+ },
44
+ };
45
+ // Role-bound only. `success` is deliberately absent: an honest state is never green.
46
+ const TONE_SURFACE = {
47
+ neutral: 'border-border bg-muted/30 text-muted-foreground',
48
+ info: 'border-primary/30 bg-primary/5 text-primary',
49
+ warning: 'border-status-warning/40 bg-status-warning/5 text-status-warning',
50
+ destructive: 'border-destructive/40 bg-destructive/5 text-destructive',
51
+ };
52
+ // ---------------------------------------------------------------------------
53
+ // Icons — inline SVG, `currentColor` only, so they inherit the tone token.
54
+ // (The package has no icon dependency; `loading-spinner.tsx` sets this
55
+ // precedent. Adding lucide-react here would push it onto every consumer.)
56
+ // ---------------------------------------------------------------------------
57
+ function StateIcon({ icon }) {
58
+ const common = {
59
+ viewBox: '0 0 24 24',
60
+ fill: 'none',
61
+ stroke: 'currentColor',
62
+ strokeWidth: 1.75,
63
+ strokeLinecap: 'round',
64
+ strokeLinejoin: 'round',
65
+ className: 'h-6 w-6',
66
+ 'aria-hidden': true,
67
+ };
68
+ switch (icon) {
69
+ case 'spinner':
70
+ return (_jsxs("svg", { ...common, className: cn(common.className, 'animate-spin'), children: [_jsx("circle", { cx: "12", cy: "12", r: "9", className: "opacity-25" }), _jsx("path", { d: "M21 12a9 9 0 0 0-9-9", className: "opacity-90" })] }));
71
+ case 'empty':
72
+ // An empty tray — "we looked, there was nothing".
73
+ return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M3 14h4l1.5 3h7L17 14h4" }), _jsx("path", { d: "M5 14 7 5h10l2 9v5H5z" })] }));
74
+ case 'alert':
75
+ return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M12 3.5 2.5 20h19L12 3.5z" }), _jsx("path", { d: "M12 10v4" }), _jsx("path", { d: "M12 17.5h.01" })] }));
76
+ case 'lock':
77
+ return (_jsxs("svg", { ...common, children: [_jsx("rect", { x: "4", y: "10", width: "16", height: "10", rx: "2" }), _jsx("path", { d: "M8 10V7a4 4 0 0 1 8 0v3" }), _jsx("path", { d: "M12 14v2" })] }));
78
+ case 'unplugged':
79
+ return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M7 3v5" }), _jsx("path", { d: "M13 3v5" }), _jsx("path", { d: "M4 8h12v3a6 6 0 0 1-6 6v4" }), _jsx("path", { d: "m3 3 18 18" })] }));
80
+ }
81
+ }
82
+ /**
83
+ * Diagonal hatch. This is the load-bearing visual: hatched ground is not a
84
+ * texture any populated surface in the estate uses, so "this panel is not
85
+ * data" is legible before a single word is read. `currentColor` keeps it on
86
+ * the tone token in both themes.
87
+ */
88
+ function Hatch() {
89
+ return (_jsxs("svg", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 h-full w-full opacity-[0.07]", preserveAspectRatio: "none", children: [_jsx("defs", { children: _jsx("pattern", { id: "bs-du-hatch", width: "8", height: "8", patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)", children: _jsx("line", { x1: "0", y1: "0", x2: "0", y2: "8", stroke: "currentColor", strokeWidth: "2" }) }) }), _jsx("rect", { width: "100%", height: "100%", fill: "url(#bs-du-hatch)" })] }));
90
+ }
91
+ export function DataUnavailable({ state, title, description, reason, onRetry, retryLabel = 'Try again', action, size = 'block', className, ...props }) {
92
+ const preset = PRESETS[state];
93
+ const showRetry = Boolean(onRetry) && state !== 'forbidden';
94
+ return (_jsxs("div", { "data-slot": "data-unavailable", "data-state": state, role: preset.role, "aria-live": preset.live, "aria-busy": state === 'loading' || undefined, className: cn(
95
+ // Dashed + hatched: deliberately unlike every populated surface.
96
+ 'relative isolate flex flex-col items-center justify-center overflow-hidden', 'rounded-lg border border-dashed text-center', size === 'compact' ? 'min-h-32 gap-2 p-4' : 'min-h-48 gap-3 p-8', TONE_SURFACE[preset.tone], className), ...props, children: [_jsx(Hatch, {}), _jsxs("div", { className: "relative flex flex-col items-center gap-3", children: [_jsx(StateIcon, { icon: preset.icon }), _jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-base font-semibold text-foreground", children: title ?? preset.title }), _jsx("p", { className: "mx-auto max-w-prose text-sm text-muted-foreground", children: description ?? preset.description })] }), reason ? (_jsxs("div", { className: "mx-auto w-full max-w-prose", children: [_jsx("p", { className: "mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: "Reason" }), _jsx("pre", { className: "overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-card/80 px-3 py-2 text-left font-mono text-xs text-muted-foreground", children: reason })] })) : null, showRetry || action ? (_jsxs("div", { className: "mt-1 flex flex-wrap items-center justify-center gap-2", children: [showRetry ? (_jsx("button", { type: "button", onClick: onRetry, className: cn('inline-flex items-center rounded-md border border-border bg-card px-3 py-1.5', 'text-sm font-medium text-foreground', 'hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'), children: retryLabel })) : null, action] })) : null] })] }));
97
+ }
98
+ /**
99
+ * Postgres/PostgREST codes that mean "refused", not "broken".
100
+ *
101
+ * 42501 insufficient_privilege · PGRST301 JWT problem ·
102
+ * PGRST116 no rows returned where one was required under RLS.
103
+ */
104
+ const FORBIDDEN_MARKERS = ['42501', 'PGRST301', 'PGRST116', 'permission denied', 'row-level security'];
105
+ function looksForbidden(error) {
106
+ if (!error)
107
+ return false;
108
+ const parts = [];
109
+ if (typeof error === 'string')
110
+ parts.push(error);
111
+ else if (typeof error === 'object') {
112
+ const e = error;
113
+ if (typeof e.message === 'string')
114
+ parts.push(e.message);
115
+ if (typeof e.code === 'string')
116
+ parts.push(e.code);
117
+ if (e.status === 401 || e.status === 403)
118
+ return true;
119
+ }
120
+ const haystack = parts.join(' ').toLowerCase();
121
+ return FORBIDDEN_MARKERS.some((m) => haystack.includes(m.toLowerCase()));
122
+ }
123
+ /** Human-readable reason string for the `reason` prop. */
124
+ export function describeError(error) {
125
+ if (!error)
126
+ return 'Unknown error';
127
+ if (typeof error === 'string')
128
+ return error;
129
+ if (error instanceof Error)
130
+ return error.message;
131
+ if (typeof error === 'object') {
132
+ const e = error;
133
+ const code = typeof e.code === 'string' ? `[${e.code}] ` : '';
134
+ if (typeof e.message === 'string')
135
+ return `${code}${e.message}`;
136
+ }
137
+ try {
138
+ return JSON.stringify(error);
139
+ }
140
+ catch {
141
+ return String(error);
142
+ }
143
+ }
144
+ /**
145
+ * Map a query result to either `'ready'` (render the real data) or the honest
146
+ * state to render instead.
147
+ *
148
+ * The point is that there is no fourth answer. A call site cannot get a
149
+ * fallback out of this function, which is what makes the class fix stick.
150
+ *
151
+ * @param isEmpty how to decide the data is genuinely empty. Defaults to
152
+ * "an array with no elements". Pass explicitly for object-shaped payloads.
153
+ */
154
+ export function resolveDataState(query, isEmpty = (d) => Array.isArray(d) && d.length === 0) {
155
+ if (query.isError)
156
+ return looksForbidden(query.error) ? 'forbidden' : 'error';
157
+ if (query.isPending ?? query.isLoading ?? false)
158
+ return 'loading';
159
+ if (query.data === undefined || query.data === null)
160
+ return 'error';
161
+ return isEmpty(query.data) ? 'empty' : 'ready';
162
+ }
@@ -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
@@ -7,9 +7,13 @@ export { D2CDefaultLogo, type D2CDefaultLogoProps } from './default-logo.js';
7
7
  export { Button, type ButtonProps, type ButtonSize, type ButtonVariant, } from './button.js';
8
8
  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, type DialogContentProps, type DialogOverlayProps, } from './dialog.js';
9
9
  export { EmptyState, type EmptyStateProps } from './empty-state.js';
10
+ export { DataUnavailable, resolveDataState, describeError, type DataUnavailableProps, type DataUnavailableState, type QueryLike, } from './data-unavailable.js';
10
11
  export { ErrorBoundary, type ErrorBoundaryProps } from './error-boundary.js';
11
12
  export { LoadingSpinner, type LoadingSpinnerProps } from './loading-spinner.js';
12
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';
13
17
  export { BrandingCard, ColorEditorSheet, OklchColorPicker, type BrandingCardProps, type ColorEditorSheetProps, type OklchColorPickerProps, } from './branding/index.js';
14
18
  export { sanitizeCustomCss } from './branding/sanitizeCustomCss.js';
15
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,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
@@ -7,9 +7,13 @@ export { D2CDefaultLogo } from './default-logo.js';
7
7
  export { Button, } from './button.js';
8
8
  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } from './dialog.js';
9
9
  export { EmptyState } from './empty-state.js';
10
+ export { DataUnavailable, resolveDataState, describeError, } from './data-unavailable.js';
10
11
  export { ErrorBoundary } from './error-boundary.js';
11
12
  export { LoadingSpinner } from './loading-spinner.js';
12
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';
13
17
  // Branding components (v0.3.0+)
14
18
  export { BrandingCard, ColorEditorSheet, OklchColorPicker, } from './branding/index.js';
15
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.0.3",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -51,6 +51,10 @@
51
51
  "import": "./dist/empty-state.js",
52
52
  "types": "./dist/empty-state.d.ts"
53
53
  },
54
+ "./data-unavailable": {
55
+ "import": "./dist/data-unavailable.js",
56
+ "types": "./dist/data-unavailable.d.ts"
57
+ },
54
58
  "./error-boundary": {
55
59
  "import": "./dist/error-boundary.js",
56
60
  "types": "./dist/error-boundary.d.ts"
@@ -63,6 +67,18 @@
63
67
  "import": "./dist/status-badge.js",
64
68
  "types": "./dist/status-badge.d.ts"
65
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
+ },
66
82
  "./assets/d2c-default-logo.svg": "./src/assets/d2c-default-logo.svg"
67
83
  },
68
84
  "scripts": {
@@ -74,23 +90,31 @@
74
90
  },
75
91
  "dependencies": {
76
92
  "@radix-ui/react-dialog": "^1.1.16",
93
+ "@radix-ui/react-popover": "^1.1.16",
77
94
  "clsx": "^2.1.1",
95
+ "cmdk": "^1.1.1",
96
+ "lucide-react": "^1.17.0",
78
97
  "react-error-boundary": "^6.1.2",
79
98
  "tailwind-merge": "^3.6.0"
80
99
  },
81
100
  "peerDependencies": {
82
101
  "@bsuite/theme": ">=0.7.0",
102
+ "@supabase/postgrest-js": "^2",
83
103
  "react": "^18.3.0 || ^19.0.0",
84
104
  "react-dom": "^18.3.0 || ^19.0.0"
85
105
  },
86
106
  "peerDependenciesMeta": {
87
107
  "@bsuite/theme": {
88
108
  "optional": true
109
+ },
110
+ "@supabase/postgrest-js": {
111
+ "optional": true
89
112
  }
90
113
  },
91
114
  "devDependencies": {
92
- "@bsuite/theme": "^0.7.0",
93
- "@storybook/react-vite": "^10.4.3",
115
+ "@bsuite/theme": "^0.11.0",
116
+ "@storybook/react-vite": "^10.5.8",
117
+ "@supabase/postgrest-js": "^2.108.1",
94
118
  "@tailwindcss/vite": "^4.3.0",
95
119
  "@testing-library/jest-dom": "^6.9.1",
96
120
  "@testing-library/react": "^16.3.2",
@@ -101,7 +125,7 @@
101
125
  "jsdom": "^29.1.1",
102
126
  "react": "^19.2.7",
103
127
  "react-dom": "^19.2.7",
104
- "storybook": "^10.4.3",
128
+ "storybook": "^10.5.8",
105
129
  "tailwindcss": "^4.3.0",
106
130
  "typescript": "~6.0.3",
107
131
  "vite": "^8.0.16",