@dbx-tools/ui 0.0.0-stage → 0.9.61

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.
@@ -0,0 +1,17 @@
1
+ // React surface for `@dbx-tools/ui/email`: a read-only Approve / Deny card for
2
+ // the `send_email` tool's approval flow, the field preview it wraps, a standard
3
+ // editable compose view for use outside a chat bubble. The email components
4
+ // share `./fields` and `./email-body`, so a drafted message renders identically
5
+ // across them. Styled with AppKit tokens.
6
+
7
+ export type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
8
+ export {
9
+ EmailApprovalCard,
10
+ EmailPreview,
11
+ type EmailApprovalCardProps,
12
+ type EmailPreviewProps,
13
+ } from "./email-approval-card.tsx";
14
+ export { EmailBody, type EmailBodyProps } from "./email-body.tsx";
15
+ export { EmailComposeView, type EmailComposeProps } from "./email-compose.tsx";
16
+ export { attachmentNames, joinAddresses, parseAddresses } from "./fields.ts";
17
+ export type { EmailDraft } from "./fields.ts";
@@ -0,0 +1,20 @@
1
+ /*
2
+ * @dbx-tools/ui/email stylesheet.
3
+ *
4
+ * Import once from the host app's Tailwind entry, after Tailwind and
5
+ * your AppKit-UI theme:
6
+ *
7
+ * @import "tailwindcss";
8
+ * @import "@databricks/appkit-ui/styles.css";
9
+ * @import "@dbx-tools/ui/email/styles.css";
10
+ *
11
+ * Defines NO design tokens - the email preview styles exclusively with
12
+ * AppKit semantic tokens and inherits the host app's theme.
13
+ *
14
+ * Pulls shared Streamdown / shiki styling from `@dbx-tools/ui` and
15
+ * registers this package's React components with Tailwind via `@source`.
16
+ */
17
+
18
+ @import "@dbx-tools/ui/styles.css";
19
+
20
+ @source "./react";
@@ -0,0 +1,9 @@
1
+ // Re-export of AppKit's React UI kit so the `@dbx-tools/*` UI packages import
2
+ // their primitives (Button, Input, cn, etc.) through one stable specifier -
3
+ // `@dbx-tools/ui/react` - and resolve AppKit + React from this package's
4
+ // dependencies rather than each consumer's.
5
+ //
6
+ // This lives in a NAMED module (not the `index.ts` barrel) so package discovery
7
+ // - which ignores barrels + `.css` - still sees `ui-appkit` as a real package.
8
+
9
+ export * from "@databricks/appkit-ui/react";
@@ -0,0 +1,285 @@
1
+ import {
2
+ Button,
3
+ Card,
4
+ CardContent,
5
+ CardDescription,
6
+ CardHeader,
7
+ CardTitle,
8
+ Input,
9
+ Label,
10
+ Separator,
11
+ cn,
12
+ } from "@databricks/appkit-ui/react";
13
+ import { brandUtils, object } from "@dbx-tools/shared-core";
14
+ import { useEffect, useId, useMemo, useState } from "react";
15
+
16
+ import { useBrand } from "../branding/react/index.ts";
17
+
18
+ const COLOR_FIELDS = [
19
+ ["primary", "Primary"],
20
+ ["primaryHover", "Primary hover"],
21
+ ["accent", "Accent"],
22
+ ["foreground", "Foreground"],
23
+ ["background", "Background"],
24
+ ["surface", "Surface"],
25
+ ["muted", "Muted"],
26
+ ["border", "Border"],
27
+ ] as const;
28
+
29
+ type ColorField = (typeof COLOR_FIELDS)[number][0];
30
+
31
+ /** One complete brand option shown above the editable fields. */
32
+ export interface BrandPreset {
33
+ id: string;
34
+ label: string;
35
+ description?: string;
36
+ context: brandUtils.BrandContextInput;
37
+ }
38
+
39
+ /** Props for {@link BrandPicker}. */
40
+ export interface BrandPickerProps {
41
+ /** Current brand. Defaults to the nearest `BrandProvider` context. */
42
+ value?: brandUtils.BrandContextInput;
43
+ /** Receives each valid edited or selected brand context. */
44
+ onChange: (value: brandUtils.BrandContext) => void;
45
+ /** Optional complete brand choices rendered as quick-select buttons. */
46
+ presets?: readonly BrandPreset[];
47
+ /** Value restored by Reset. Defaults to the dbx-tools brand. */
48
+ resetValue?: brandUtils.BrandContextInput;
49
+ /** Show editable icon, logo, and favicon references. */
50
+ showAssets?: boolean;
51
+ className?: string;
52
+ }
53
+
54
+ const validColor = (value: string, fallback: string): string =>
55
+ /^#(?:[\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/i.test(value) ? value : fallback;
56
+
57
+ /**
58
+ * Edit a portable `BrandContext` with AppKit-native controls.
59
+ *
60
+ * The picker keeps incomplete text locally while emitting only schema-valid
61
+ * contexts, so controlled hosts can apply changes directly to a
62
+ * `BrandProvider` without guarding every keystroke.
63
+ */
64
+ export function BrandPicker({
65
+ value,
66
+ onChange,
67
+ presets = [],
68
+ resetValue = brandUtils.defaultBrandContext,
69
+ showAssets = true,
70
+ className,
71
+ }: BrandPickerProps) {
72
+ const { context: inherited } = useBrand();
73
+ const current = useMemo(
74
+ () => brandUtils.parseBrandContext(value ?? inherited),
75
+ [inherited, value],
76
+ );
77
+ const reset = useMemo(() => brandUtils.parseBrandContext(resetValue), [resetValue]);
78
+ const resolvedPresets = useMemo(
79
+ () =>
80
+ presets.map((preset) => ({
81
+ ...preset,
82
+ context: brandUtils.parseBrandContext(preset.context),
83
+ })),
84
+ [presets],
85
+ );
86
+ const [draft, setDraft] = useState(current);
87
+ const id = useId();
88
+
89
+ useEffect(() => setDraft(current), [current]);
90
+
91
+ const update = (next: brandUtils.BrandContext) => {
92
+ setDraft(next);
93
+ const parsed = brandUtils.BrandContextSchema.safeParse(next);
94
+ if (parsed.success) onChange(parsed.data);
95
+ };
96
+
97
+ const updateIdentity = (field: "name" | "shortName" | "tagline", next: string) =>
98
+ update({ ...draft, [field]: next });
99
+
100
+ const updateColor = (field: ColorField, next: string) =>
101
+ update({ ...draft, colors: { ...draft.colors, [field]: next } });
102
+
103
+ const updateAsset = (group: "icon" | "logo", mode: "light" | "dark", next: string) =>
104
+ update({
105
+ ...draft,
106
+ assets: {
107
+ ...draft.assets,
108
+ [group]: { ...draft.assets[group], [mode]: next || undefined },
109
+ },
110
+ });
111
+
112
+ return (
113
+ <Card className={cn("overflow-hidden", className)}>
114
+ <CardHeader>
115
+ <div className="flex flex-wrap items-start justify-between gap-3">
116
+ <div>
117
+ <CardTitle>Brand picker</CardTitle>
118
+ <CardDescription>
119
+ Update identity, AppKit color tokens, document metadata, and email presentation.
120
+ </CardDescription>
121
+ </div>
122
+ <Button
123
+ type="button"
124
+ size="sm"
125
+ variant="outline"
126
+ onClick={() => {
127
+ setDraft(reset);
128
+ onChange(reset);
129
+ }}
130
+ >
131
+ Reset
132
+ </Button>
133
+ </div>
134
+ </CardHeader>
135
+ <CardContent className="space-y-6">
136
+ {resolvedPresets.length ? (
137
+ <section className="space-y-2" aria-label="Brand presets">
138
+ <Label>Presets</Label>
139
+ <div className="grid gap-2 sm:grid-cols-3">
140
+ {resolvedPresets.map((preset) => {
141
+ const active = object.deepEqual(current, preset.context);
142
+ return (
143
+ <Button
144
+ key={preset.id}
145
+ type="button"
146
+ variant={active ? "default" : "outline"}
147
+ className="h-auto justify-start px-3 py-2 text-left"
148
+ onClick={() => {
149
+ setDraft(preset.context);
150
+ onChange(preset.context);
151
+ }}
152
+ >
153
+ <span>
154
+ <span className="block text-sm font-medium">{preset.label}</span>
155
+ {preset.description ? (
156
+ <span className="block text-xs font-normal opacity-75">
157
+ {preset.description}
158
+ </span>
159
+ ) : null}
160
+ </span>
161
+ </Button>
162
+ );
163
+ })}
164
+ </div>
165
+ </section>
166
+ ) : null}
167
+
168
+ <Separator />
169
+
170
+ <section className="space-y-3">
171
+ <div>
172
+ <h3 className="text-sm font-medium text-foreground">Identity</h3>
173
+ <p className="text-xs text-muted-foreground">
174
+ Used by navigation, metadata, and email.
175
+ </p>
176
+ </div>
177
+ <div className="grid gap-3 sm:grid-cols-2">
178
+ <div className="space-y-1.5">
179
+ <Label htmlFor={`${id}-name`}>Name</Label>
180
+ <Input
181
+ id={`${id}-name`}
182
+ value={draft.name}
183
+ onChange={(event) => updateIdentity("name", event.currentTarget.value)}
184
+ />
185
+ </div>
186
+ <div className="space-y-1.5">
187
+ <Label htmlFor={`${id}-short-name`}>Short name</Label>
188
+ <Input
189
+ id={`${id}-short-name`}
190
+ value={draft.shortName}
191
+ onChange={(event) => updateIdentity("shortName", event.currentTarget.value)}
192
+ />
193
+ </div>
194
+ <div className="space-y-1.5 sm:col-span-2">
195
+ <Label htmlFor={`${id}-tagline`}>Tagline</Label>
196
+ <Input
197
+ id={`${id}-tagline`}
198
+ value={draft.tagline}
199
+ onChange={(event) => updateIdentity("tagline", event.currentTarget.value)}
200
+ />
201
+ </div>
202
+ </div>
203
+ </section>
204
+
205
+ <Separator />
206
+
207
+ <section className="space-y-3">
208
+ <div>
209
+ <h3 className="text-sm font-medium text-foreground">Color system</h3>
210
+ <p className="text-xs text-muted-foreground">
211
+ Valid hex values are emitted immediately to the active brand provider.
212
+ </p>
213
+ </div>
214
+ <div className="grid gap-3 sm:grid-cols-2">
215
+ {COLOR_FIELDS.map(([field, label]) => (
216
+ <div key={field} className="space-y-1.5">
217
+ <Label htmlFor={`${id}-${field}`}>{label}</Label>
218
+ <div className="flex gap-2">
219
+ <Input
220
+ type="color"
221
+ aria-label={`${label} color`}
222
+ value={validColor(draft.colors[field], current.colors[field])}
223
+ className="h-9 w-12 shrink-0 cursor-pointer p-1"
224
+ onChange={(event) => updateColor(field, event.currentTarget.value)}
225
+ />
226
+ <Input
227
+ id={`${id}-${field}`}
228
+ value={draft.colors[field]}
229
+ spellCheck={false}
230
+ onChange={(event) => updateColor(field, event.currentTarget.value)}
231
+ />
232
+ </div>
233
+ </div>
234
+ ))}
235
+ </div>
236
+ </section>
237
+
238
+ {showAssets ? (
239
+ <>
240
+ <Separator />
241
+ <section className="space-y-3">
242
+ <div>
243
+ <h3 className="text-sm font-medium text-foreground">Assets</h3>
244
+ <p className="text-xs text-muted-foreground">
245
+ Package paths, data URLs, and hosted URLs are supported.
246
+ </p>
247
+ </div>
248
+ <div className="grid gap-3 sm:grid-cols-2">
249
+ {(["icon", "logo"] as const).flatMap((group) =>
250
+ (["light", "dark"] as const).map((mode) => (
251
+ <div key={`${group}-${mode}`} className="space-y-1.5">
252
+ <Label htmlFor={`${id}-${group}-${mode}`}>
253
+ {group === "icon" ? "Icon" : "Logo"} · {mode}
254
+ </Label>
255
+ <Input
256
+ id={`${id}-${group}-${mode}`}
257
+ value={draft.assets[group][mode] ?? ""}
258
+ spellCheck={false}
259
+ onChange={(event) => updateAsset(group, mode, event.currentTarget.value)}
260
+ />
261
+ </div>
262
+ )),
263
+ )}
264
+ <div className="space-y-1.5 sm:col-span-2">
265
+ <Label htmlFor={`${id}-favicon`}>Favicon</Label>
266
+ <Input
267
+ id={`${id}-favicon`}
268
+ value={draft.assets.favicon}
269
+ spellCheck={false}
270
+ onChange={(event) =>
271
+ update({
272
+ ...draft,
273
+ assets: { ...draft.assets, favicon: event.currentTarget.value },
274
+ })
275
+ }
276
+ />
277
+ </div>
278
+ </div>
279
+ </section>
280
+ </>
281
+ ) : null}
282
+ </CardContent>
283
+ </Card>
284
+ );
285
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./appkit-ui.ts";
2
+ export * from "./brand-picker.tsx";
@@ -0,0 +1,16 @@
1
+ // React surface for `@dbx-tools/ui/search`: a drop-in `SearchBox`
2
+ // (search-as-you-type over Databricks AI Search), a `SearchResults` list for a
3
+ // full-page layout, and the `useSearch` hook they share. Single-index queries
4
+ // delegate to AppKit's native `useAiSearchQuery`; universal search uses the
5
+ // dbx-tools extension route.
6
+ // Styled with AppKit tokens (import `@dbx-tools/ui/search/styles.css`).
7
+
8
+ export type {
9
+ SearchHit,
10
+ SearchResult,
11
+ SearchMode,
12
+ SearchClientConfig,
13
+ } from "@dbx-tools/shared-search";
14
+ export { SearchBox, type SearchBoxProps } from "./search-box.tsx";
15
+ export { SearchResults, type SearchResultsProps } from "./search-results.tsx";
16
+ export { useSearch, type UseSearchOptions, type UseSearchState } from "./use-search.ts";
@@ -0,0 +1,153 @@
1
+ // `SearchBox` is a drop-in search-as-you-type input for
2
+ // Databricks AI Search. It wires an AppKit `Input` to the {@link useSearch}
3
+ // hook and renders hits in a dropdown as the user types. AppKit's native
4
+ // `aiSearch` client config supplies the index alias and query route.
5
+ //
6
+ // It is presentational and unopinionated about what a hit looks like: pass a
7
+ // `renderHit` to control each row, or rely on the default which shows the first
8
+ // string-ish field as a title and the primary-key `id` as a subtitle. Styled
9
+ // with AppKit tokens (see `../styles.css`).
10
+
11
+ import type { SearchHit } from "@dbx-tools/shared-search";
12
+ import { XIcon } from "lucide-react";
13
+ import { useCallback, useState, type ReactNode } from "react";
14
+
15
+ import { useSearch, type UseSearchOptions } from "./use-search.ts";
16
+ import {
17
+ Badge,
18
+ Button,
19
+ Command,
20
+ CommandEmpty,
21
+ CommandInput,
22
+ CommandItem,
23
+ CommandList,
24
+ Spinner,
25
+ cn,
26
+ } from "../../react/index.ts";
27
+
28
+ /** Props for {@link SearchBox}. */
29
+ export interface SearchBoxProps extends UseSearchOptions {
30
+ /** Placeholder text for the input. */
31
+ placeholder?: string;
32
+ /** Called when the user picks a hit (click or Enter on a focused row). */
33
+ onSelect?: (hit: SearchHit) => void;
34
+ /** Render one hit row. Defaults to a title + id + score badge. */
35
+ renderHit?: (hit: SearchHit) => ReactNode;
36
+ /** Show the source index name on each hit (useful for universal search). */
37
+ showIndex?: boolean;
38
+ /** Extra class names for the outer container. */
39
+ className?: string;
40
+ }
41
+
42
+ /** Pick the most title-like string field from a hit for the default row. */
43
+ function hitTitle(hit: SearchHit): string {
44
+ for (const key of ["title", "name", "text", "content", "body"]) {
45
+ const value = hit.fields[key];
46
+ if (typeof value === "string" && value.trim()) return value;
47
+ }
48
+ const firstString = Object.values(hit.fields).find(
49
+ (value) => typeof value === "string" && value.trim(),
50
+ );
51
+ return typeof firstString === "string" ? firstString : hit.id;
52
+ }
53
+
54
+ /**
55
+ * A search-as-you-type box over AI Search. Renders an input and a results
56
+ * dropdown, debouncing keystrokes and cancelling stale requests through
57
+ * {@link useSearch}.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * import { SearchBox } from "@dbx-tools/ui/search/react";
62
+ * import "@dbx-tools/ui/search/styles.css";
63
+ *
64
+ * <SearchBox placeholder="Search docs…" onSelect={(hit) => open(hit.id)} />
65
+ * ```
66
+ */
67
+ export function SearchBox({
68
+ placeholder = "Search…",
69
+ onSelect,
70
+ renderHit,
71
+ showIndex,
72
+ className,
73
+ ...searchOptions
74
+ }: SearchBoxProps): ReactNode {
75
+ const { query, setQuery, hits, loading, error, clear, submit } = useSearch(searchOptions);
76
+ const [open, setOpen] = useState(false);
77
+
78
+ const handleSelect = useCallback(
79
+ (hit: SearchHit) => {
80
+ onSelect?.(hit);
81
+ setOpen(false);
82
+ },
83
+ [onSelect],
84
+ );
85
+
86
+ return (
87
+ <Command
88
+ shouldFilter={false}
89
+ className={cn("dbx-search-box h-auto overflow-visible bg-transparent", className)}
90
+ onKeyDown={(event) => {
91
+ if (event.key === "Escape") setOpen(false);
92
+ }}
93
+ >
94
+ <div className="dbx-search-box__field">
95
+ <CommandInput
96
+ value={query}
97
+ placeholder={placeholder}
98
+ aria-label={placeholder}
99
+ onValueChange={(value) => {
100
+ setQuery(value);
101
+ setOpen(true);
102
+ }}
103
+ onFocus={() => setOpen(true)}
104
+ onKeyDown={(event) => {
105
+ if (event.key === "Enter" && hits.length === 0) submit();
106
+ }}
107
+ />
108
+ {loading ? <Spinner className="dbx-search-box__spinner" /> : null}
109
+ {query ? (
110
+ <Button
111
+ variant="ghost"
112
+ size="icon"
113
+ aria-label="Clear search"
114
+ className="dbx-search-box__clear"
115
+ onClick={() => {
116
+ clear();
117
+ setOpen(false);
118
+ }}
119
+ >
120
+ <XIcon />
121
+ </Button>
122
+ ) : null}
123
+ </div>
124
+
125
+ {open && (query.trim() || error) ? (
126
+ <CommandList className="dbx-search-box__panel dbx-search-box__results">
127
+ {error ? <div className="dbx-search-box__error">{error}</div> : null}
128
+ {!error && !loading ? <CommandEmpty>No results</CommandEmpty> : null}
129
+ {hits.map((hit) => (
130
+ <CommandItem
131
+ key={`${hit.index ?? ""}:${hit.id}`}
132
+ value={JSON.stringify([hit.index ?? "", hit.id])}
133
+ className="dbx-search-box__hit"
134
+ onSelect={() => handleSelect(hit)}
135
+ >
136
+ {renderHit ? (
137
+ renderHit(hit)
138
+ ) : (
139
+ <div className="dbx-search-box__hit-default">
140
+ <span className="dbx-search-box__hit-title">{hitTitle(hit)}</span>
141
+ <span className="dbx-search-box__hit-meta">
142
+ {showIndex && hit.index ? <Badge variant="secondary">{hit.index}</Badge> : null}
143
+ <span className="dbx-search-box__hit-id">{hit.id}</span>
144
+ </span>
145
+ </div>
146
+ )}
147
+ </CommandItem>
148
+ ))}
149
+ </CommandList>
150
+ ) : null}
151
+ </Command>
152
+ );
153
+ }
@@ -0,0 +1,76 @@
1
+ // `SearchResults` — a presentational list of AI Search hits for a full-page
2
+ // results layout (as opposed to the `SearchBox` dropdown). Give it `hits` from
3
+ // `useSearch` (or any `SearchResult`) and it renders a scored, index-tagged
4
+ // list; pass `renderHit` to control a row. Styled with AppKit tokens.
5
+
6
+ import type { SearchHit } from "@dbx-tools/shared-search";
7
+ import type { ReactNode } from "react";
8
+
9
+ import { Badge, cn } from "../../react/index.ts";
10
+
11
+ /** Props for {@link SearchResults}. */
12
+ export interface SearchResultsProps {
13
+ /** The hits to render, most relevant first. */
14
+ hits: SearchHit[];
15
+ /** Called when a hit is clicked. */
16
+ onSelect?: (hit: SearchHit) => void;
17
+ /** Render one hit row. Defaults to a title, fields, and a score badge. */
18
+ renderHit?: (hit: SearchHit) => ReactNode;
19
+ /** Show each hit's source index (useful for universal search). */
20
+ showIndex?: boolean;
21
+ /** Message shown when there are no hits. */
22
+ emptyMessage?: ReactNode;
23
+ /** Extra class names for the container. */
24
+ className?: string;
25
+ }
26
+
27
+ function hitTitle(hit: SearchHit): string {
28
+ for (const key of ["title", "name", "text", "content", "body"]) {
29
+ const value = hit.fields[key];
30
+ if (typeof value === "string" && value.trim()) return value;
31
+ }
32
+ return hit.id;
33
+ }
34
+
35
+ /** A full-page list of search hits. */
36
+ export function SearchResults({
37
+ hits,
38
+ onSelect,
39
+ renderHit,
40
+ showIndex,
41
+ emptyMessage = "No results",
42
+ className,
43
+ }: SearchResultsProps): ReactNode {
44
+ if (hits.length === 0) {
45
+ return <div className={cn("dbx-search-results__empty", className)}>{emptyMessage}</div>;
46
+ }
47
+ return (
48
+ <ul className={cn("dbx-search-results", className)}>
49
+ {hits.map((hit) => (
50
+ <li key={`${hit.index ?? ""}:${hit.id}`} className="dbx-search-results__item">
51
+ <button
52
+ type="button"
53
+ className="dbx-search-results__button"
54
+ onClick={() => onSelect?.(hit)}
55
+ >
56
+ {renderHit ? (
57
+ renderHit(hit)
58
+ ) : (
59
+ <>
60
+ <div className="dbx-search-results__header">
61
+ <span className="dbx-search-results__title">{hitTitle(hit)}</span>
62
+ <Badge variant="outline">{hit.score.toFixed(3)}</Badge>
63
+ </div>
64
+ {showIndex && hit.index ? (
65
+ <Badge variant="secondary" className="dbx-search-results__index">
66
+ {hit.index}
67
+ </Badge>
68
+ ) : null}
69
+ </>
70
+ )}
71
+ </button>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ );
76
+ }