@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,188 @@
1
+ // `useSearch` adapts AppKit's native `useAiSearchQuery` to a debounced
2
+ // search-as-you-type state machine. Single-index queries use AppKit's native
3
+ // route and client config; universal queries use the dbx-tools extension route.
4
+
5
+ import { useAiSearchQuery, type AiSearchRequest } from "@databricks/appkit-ui/react/beta";
6
+ import {
7
+ searchSchemas,
8
+ type SearchClientConfig,
9
+ type SearchHit,
10
+ type SearchMode,
11
+ type SearchResult,
12
+ } from "@dbx-tools/shared-search";
13
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
14
+
15
+ /** Options for {@link useSearch}. */
16
+ export interface UseSearchOptions {
17
+ /** Which index to search (name or alias). Defaults to the plugin's default index. */
18
+ index?: string;
19
+ /** Search every configured index and merge results (universal search). */
20
+ universal?: boolean;
21
+ /** Match mode. Defaults to the plugin's configured mode. */
22
+ mode?: SearchMode;
23
+ /** Max hits to request. Defaults to the plugin's configured page size. */
24
+ limit?: number;
25
+ /** Debounce in ms before a keystroke triggers a request. Defaults to 200. */
26
+ debounceMs?: number;
27
+ /** Minimum query length before searching. Defaults to 1. */
28
+ minLength?: number;
29
+ }
30
+
31
+ /** The state {@link useSearch} returns. */
32
+ export interface UseSearchState {
33
+ /** The current query text. */
34
+ query: string;
35
+ /** Set the query (triggers a debounced search). */
36
+ setQuery: (query: string) => void;
37
+ /** The latest hits, most relevant first. */
38
+ hits: SearchHit[];
39
+ /** True while a request is in flight. */
40
+ loading: boolean;
41
+ /** The last error, if any. */
42
+ error: string | null;
43
+ /** The resolved plugin config (indexes, default, page size). */
44
+ config: SearchClientConfig | undefined;
45
+ /** Run the search immediately (bypassing the debounce). */
46
+ submit: () => void;
47
+ /** Clear the query and hits. */
48
+ clear: () => void;
49
+ }
50
+
51
+ const UNIVERSAL_SEARCH_PATH = "/api/search/universal";
52
+
53
+ /** Validate one universal-search response with the owning shared schema. */
54
+ export function parseUniversalSearchResult(value: unknown): SearchResult {
55
+ return searchSchemas.searchResultSchema.parse(value);
56
+ }
57
+
58
+ function toHits(
59
+ results: Array<{ score: number; data: Record<string, unknown> }>,
60
+ index: string | null,
61
+ ): SearchHit[] {
62
+ return results.map((result, resultIndex) => ({
63
+ id: String(result.data.id ?? Object.values(result.data)[0] ?? resultIndex),
64
+ score: result.score,
65
+ fields: result.data,
66
+ ...(index ? { index } : {}),
67
+ }));
68
+ }
69
+
70
+ /**
71
+ * Search-as-you-type against AppKit AI Search. Debounces input, cancels stale
72
+ * native queries, and exposes `{ query, setQuery, hits, loading, error }` for a
73
+ * search box to render.
74
+ */
75
+ export function useSearch(options: UseSearchOptions = {}): UseSearchState {
76
+ const native = useAiSearchQuery({ ...(options.index ? { alias: options.index } : {}) });
77
+ const debounceMs = options.debounceMs ?? 200;
78
+ const minLength = options.minLength ?? 1;
79
+
80
+ const [query, setQueryState] = useState("");
81
+ const [hits, setHits] = useState<SearchHit[]>([]);
82
+ const [universalLoading, setUniversalLoading] = useState(false);
83
+ const [universalError, setUniversalError] = useState<string | null>(null);
84
+
85
+ const abortRef = useRef<AbortController | null>(null);
86
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
87
+
88
+ const run = useCallback(
89
+ async (text: string) => {
90
+ abortRef.current?.abort();
91
+ if (text.trim().length < minLength) {
92
+ setHits([]);
93
+ setUniversalLoading(false);
94
+ return;
95
+ }
96
+ if (!options.universal) {
97
+ const resolvedQueryType = searchSchemas.toAiSearchQueryType(options.mode);
98
+ const request: AiSearchRequest = {
99
+ queryText: text,
100
+ ...(options.limit ? { numResults: options.limit } : {}),
101
+ ...(resolvedQueryType ? { queryType: resolvedQueryType } : {}),
102
+ };
103
+ const result = await native.search(request);
104
+ setHits(result ? toHits(result.results, native.alias) : []);
105
+ return;
106
+ }
107
+ const controller = new AbortController();
108
+ abortRef.current = controller;
109
+ setUniversalLoading(true);
110
+ setUniversalError(null);
111
+ try {
112
+ const response = await fetch(UNIVERSAL_SEARCH_PATH, {
113
+ method: "POST",
114
+ headers: { "content-type": "application/json" },
115
+ body: JSON.stringify({
116
+ query: text,
117
+ ...(options.limit ? { limit: options.limit } : {}),
118
+ ...(options.mode ? { mode: options.mode } : {}),
119
+ }),
120
+ signal: controller.signal,
121
+ });
122
+ if (!response.ok) {
123
+ throw new Error(`search failed (${response.status})`);
124
+ }
125
+ const result = parseUniversalSearchResult(await response.json());
126
+ setHits(result.hits);
127
+ } catch (err) {
128
+ if ((err as Error).name === "AbortError") return;
129
+ setUniversalError((err as Error).message);
130
+ setHits([]);
131
+ } finally {
132
+ if (abortRef.current === controller) setUniversalLoading(false);
133
+ }
134
+ },
135
+ [minLength, native.alias, native.search, options.limit, options.mode, options.universal],
136
+ );
137
+
138
+ const setQuery = useCallback(
139
+ (text: string) => {
140
+ setQueryState(text);
141
+ if (timerRef.current) clearTimeout(timerRef.current);
142
+ timerRef.current = setTimeout(() => void run(text), debounceMs);
143
+ },
144
+ [debounceMs, run],
145
+ );
146
+
147
+ const submit = useCallback(() => {
148
+ if (timerRef.current) clearTimeout(timerRef.current);
149
+ void run(query);
150
+ }, [query, run]);
151
+
152
+ const clear = useCallback(() => {
153
+ if (timerRef.current) clearTimeout(timerRef.current);
154
+ abortRef.current?.abort();
155
+ setQueryState("");
156
+ setHits([]);
157
+ setUniversalError(null);
158
+ setUniversalLoading(false);
159
+ }, []);
160
+
161
+ useEffect(
162
+ () => () => {
163
+ if (timerRef.current) clearTimeout(timerRef.current);
164
+ abortRef.current?.abort();
165
+ },
166
+ [],
167
+ );
168
+
169
+ const config = useMemo<SearchClientConfig>(
170
+ () => ({
171
+ indexes: native.indexes.map((index) => ({
172
+ name: index.alias,
173
+ alias: index.alias,
174
+ isDefault: index.alias === native.alias,
175
+ })),
176
+ ...(native.alias ? { defaultIndex: native.alias } : {}),
177
+ pageSize: options.limit ?? 20,
178
+ basePath: "/api/ai-search",
179
+ }),
180
+ [native.alias, native.indexes, options.limit],
181
+ );
182
+ const loading = options.universal ? universalLoading : native.loading;
183
+ const error = options.universal ? universalError : native.error;
184
+ return useMemo(
185
+ () => ({ query, setQuery, hits, loading, error, config, submit, clear }),
186
+ [query, setQuery, hits, loading, error, config, submit, clear],
187
+ );
188
+ }
@@ -0,0 +1,145 @@
1
+ /*
2
+ * @dbx-tools/ui/search 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/search/styles.css";
10
+ *
11
+ * Defines NO design tokens - the search box / results style exclusively with
12
+ * AppKit semantic tokens and inherit the host app's theme. Registers this
13
+ * package's React components with Tailwind via `@source`.
14
+ */
15
+
16
+ @import "@dbx-tools/ui/styles.css";
17
+
18
+ @source "./react";
19
+
20
+ .dbx-search-box {
21
+ position: relative;
22
+ width: 100%;
23
+ }
24
+
25
+ .dbx-search-box__field {
26
+ position: relative;
27
+ display: flex;
28
+ align-items: center;
29
+ }
30
+
31
+ .dbx-search-box__field [data-slot="command-input-wrapper"] {
32
+ width: 100%;
33
+ border: 1px solid var(--border, currentColor);
34
+ border-radius: var(--radius, 0.5rem);
35
+ }
36
+
37
+ .dbx-search-box__field [data-slot="command-input"] {
38
+ padding-right: 4.5rem;
39
+ }
40
+
41
+ .dbx-search-box__spinner {
42
+ position: absolute;
43
+ right: 2.25rem;
44
+ }
45
+
46
+ .dbx-search-box__clear {
47
+ position: absolute;
48
+ right: 0;
49
+ }
50
+
51
+ .dbx-search-box__panel {
52
+ position: absolute;
53
+ z-index: 50;
54
+ margin-top: 0.25rem;
55
+ width: 100%;
56
+ overflow: hidden;
57
+ border-radius: var(--radius, 0.5rem);
58
+ border: 1px solid var(--border, currentColor);
59
+ background: var(--popover, var(--background));
60
+ color: var(--popover-foreground, var(--foreground));
61
+ box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.35);
62
+ }
63
+
64
+ .dbx-search-box__results {
65
+ max-height: 20rem;
66
+ }
67
+
68
+ .dbx-search-box__hit {
69
+ padding: 0.5rem 0.75rem;
70
+ }
71
+
72
+ .dbx-search-box__hit-default {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 0.125rem;
76
+ }
77
+
78
+ .dbx-search-box__hit-title {
79
+ font-weight: 500;
80
+ overflow: hidden;
81
+ text-overflow: ellipsis;
82
+ white-space: nowrap;
83
+ }
84
+
85
+ .dbx-search-box__hit-meta {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 0.5rem;
89
+ font-size: 0.75rem;
90
+ color: var(--muted-foreground, currentColor);
91
+ }
92
+
93
+ .dbx-search-box__empty,
94
+ .dbx-search-box__error {
95
+ padding: 0.75rem;
96
+ font-size: 0.875rem;
97
+ color: var(--muted-foreground, currentColor);
98
+ }
99
+
100
+ .dbx-search-box__error {
101
+ color: var(--destructive, currentColor);
102
+ }
103
+
104
+ .dbx-search-results {
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: 0.5rem;
108
+ list-style: none;
109
+ margin: 0;
110
+ padding: 0;
111
+ }
112
+
113
+ .dbx-search-results__button {
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: 0.25rem;
117
+ width: 100%;
118
+ text-align: left;
119
+ padding: 0.75rem 1rem;
120
+ border-radius: var(--radius, 0.5rem);
121
+ border: 1px solid var(--border, currentColor);
122
+ background: var(--card, transparent);
123
+ color: inherit;
124
+ cursor: pointer;
125
+ }
126
+
127
+ .dbx-search-results__button:hover {
128
+ background: var(--accent, rgb(0 0 0 / 0.05));
129
+ }
130
+
131
+ .dbx-search-results__header {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: space-between;
135
+ gap: 0.75rem;
136
+ }
137
+
138
+ .dbx-search-results__title {
139
+ font-weight: 500;
140
+ }
141
+
142
+ .dbx-search-results__empty {
143
+ padding: 1rem;
144
+ color: var(--muted-foreground, currentColor);
145
+ }
package/src/styles.css ADDED
@@ -0,0 +1,26 @@
1
+ /*
2
+ * @dbx-tools/ui shared stylesheet.
3
+ *
4
+ * Feature UI packages (`ui-email`, `ui-mastra`, ...) import
5
+ * this once and add their own `@source` for local class strings.
6
+ *
7
+ * Provides:
8
+ * 1. Streamdown's base stylesheet (markdown / code-block layout).
9
+ * 2. The shiki token-paint shim used by Streamdown renderers.
10
+ */
11
+
12
+ @import "tailwindcss";
13
+ @import "streamdown/styles.css";
14
+ /*
15
+ * Brand -> AppKit token bridge. Scoped to `:root[data-brand]`, so it is inert
16
+ * until a host applies a brand (see `@dbx-tools/ui/branding` `applyBrandContext`
17
+ * / `BrandProvider applyToDocument`). Imported here so it travels with every
18
+ * feature UI package that depends on this shared base.
19
+ */
20
+ @import "@dbx-tools/ui/branding/brand-bridge.css";
21
+
22
+ @source "./react";
23
+
24
+ [data-streamdown="code-block-body"] code span {
25
+ color: var(--sdm-c, inherit);
26
+ }