@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 +57 -0
- package/dist/branding/BrandingCard.d.ts.map +1 -1
- package/dist/branding/BrandingCard.js +1 -0
- package/dist/branding/ColorEditorSheet.d.ts.map +1 -1
- package/dist/branding/ColorEditorSheet.js +1 -0
- package/dist/branding/OklchColorPicker.d.ts.map +1 -1
- package/dist/branding/OklchColorPicker.js +1 -0
- package/dist/command.d.ts +85 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +29 -0
- package/dist/dialog.d.ts.map +1 -1
- package/dist/dialog.js +1 -0
- package/dist/entity-selector.d.ts +269 -0
- package/dist/entity-selector.d.ts.map +1 -0
- package/dist/entity-selector.js +330 -0
- package/dist/error-boundary.d.ts.map +1 -1
- package/dist/error-boundary.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/popover.d.ts +7 -0
- package/dist/popover.d.ts.map +1 -0
- package/dist/popover.js +10 -0
- package/package.json +23 -3
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":"
|
|
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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ColorEditorSheet.d.ts","sourceRoot":"","sources":["../../src/branding/ColorEditorSheet.tsx"],"names":[],"mappings":"
|
|
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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OklchColorPicker.d.ts","sourceRoot":"","sources":["../../src/branding/OklchColorPicker.tsx"],"names":[],"mappings":"
|
|
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"}
|
|
@@ -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"}
|
package/dist/command.js
ADDED
|
@@ -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, };
|
package/dist/dialog.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["../src/dialog.tsx"],"names":[],"mappings":"
|
|
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
|
@@ -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":"
|
|
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"}
|
package/dist/error-boundary.js
CHANGED
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/popover.js
ADDED
|
@@ -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.
|
|
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": "
|
|
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",
|