@perxel/spotlight 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +174 -2
- package/dist/build/index.d.ts +32 -0
- package/dist/build/index.js +68 -0
- package/dist/chat/index.d.ts +3 -0
- package/dist/chat/index.js +5 -0
- package/dist/chat/parts.d.ts +57 -0
- package/dist/chat/parts.js +69 -0
- package/dist/chat/useAskChat.d.ts +67 -0
- package/dist/chat/useAskChat.js +200 -0
- package/dist/core/copy.d.ts +4 -0
- package/dist/core/copy.js +40 -0
- package/dist/core/fold.d.ts +10 -0
- package/dist/core/fold.js +23 -0
- package/dist/core/messages.d.ts +24 -0
- package/dist/core/messages.js +72 -0
- package/dist/core/search.d.ts +73 -0
- package/dist/core/search.js +210 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.js +48 -0
- package/dist/gate/index.d.ts +2 -0
- package/dist/gate/index.js +5 -0
- package/dist/gate/parts.d.ts +179 -0
- package/dist/gate/parts.js +282 -0
- package/dist/gate/useSearchAsk.d.ts +90 -0
- package/dist/gate/useSearchAsk.js +122 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/liquid/context.d.ts +26 -0
- package/dist/liquid/context.js +6 -0
- package/dist/liquid/gate.d.ts +8 -0
- package/dist/liquid/gate.js +52 -0
- package/dist/liquid/icons.d.ts +12 -0
- package/dist/liquid/icons.js +15 -0
- package/dist/liquid/index.d.ts +9 -0
- package/dist/liquid/index.js +19 -0
- package/dist/liquid/liquid.css +1007 -0
- package/dist/liquid/panel.d.ts +8 -0
- package/dist/liquid/panel.js +186 -0
- package/dist/liquid/util.d.ts +6 -0
- package/dist/liquid/util.js +16 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +10 -0
- package/dist/react/provider.d.ts +52 -0
- package/dist/react/provider.js +218 -0
- package/dist/react/turnstile.d.ts +16 -0
- package/dist/react/turnstile.js +100 -0
- package/dist/server/handler.d.ts +102 -0
- package/dist/server/handler.js +274 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +5 -0
- package/dist/server/sources.d.ts +79 -0
- package/dist/server/sources.js +315 -0
- package/dist/server/turnstile.d.ts +15 -0
- package/dist/server/turnstile.js +29 -0
- package/dist/server/types.d.ts +34 -0
- package/dist/server/types.js +1 -0
- package/dist/types.d.ts +207 -0
- package/dist/types.js +7 -0
- package/package.json +102 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PersistAdapter } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Keeps the conversation in `localStorage` under `key` for `days` after the visitor's last
|
|
4
|
+
* question. Private mode or blocked storage: the conversation just does not survive a reload.
|
|
5
|
+
*/
|
|
6
|
+
export declare function localStorageAdapter({ key, days }?: {
|
|
7
|
+
key?: string;
|
|
8
|
+
days?: number;
|
|
9
|
+
}): PersistAdapter;
|
|
10
|
+
/** Nowhere: the conversation lives as long as the page. */
|
|
11
|
+
export declare const noPersist: PersistAdapter;
|
|
12
|
+
/**
|
|
13
|
+
* What a question from the gate does: `none` (no conversation), `recent` (continues it) or
|
|
14
|
+
* `stale` (kept, but its last question is older than `continueHours`: a new one starts).
|
|
15
|
+
*/
|
|
16
|
+
export type ConversationState = "none" | "recent" | "stale";
|
|
17
|
+
export declare function conversationState(persist: PersistAdapter, continueHours?: number): ConversationState;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The conversation kept in the browser. No React, no AI SDK: the gate asks whether there is a
|
|
2
|
+
// conversation without loading the chat code.
|
|
3
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Keeps the conversation in `localStorage` under `key` for `days` after the visitor's last
|
|
6
|
+
* question. Private mode or blocked storage: the conversation just does not survive a reload.
|
|
7
|
+
*/
|
|
8
|
+
export function localStorageAdapter({ key = "spotlight-chat", days = 7 } = {}) {
|
|
9
|
+
return {
|
|
10
|
+
read() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = localStorage.getItem(key);
|
|
13
|
+
if (!raw)
|
|
14
|
+
return null;
|
|
15
|
+
const v = JSON.parse(raw);
|
|
16
|
+
if (typeof v.id !== "string" || !Array.isArray(v.messages))
|
|
17
|
+
return null;
|
|
18
|
+
if (!(Date.now() - v.savedAt < days * DAY)) {
|
|
19
|
+
localStorage.removeItem(key);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
write(v) {
|
|
29
|
+
try {
|
|
30
|
+
if (v)
|
|
31
|
+
localStorage.setItem(key, JSON.stringify(v));
|
|
32
|
+
else
|
|
33
|
+
localStorage.removeItem(key);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Storage blocked.
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Nowhere: the conversation lives as long as the page. */
|
|
42
|
+
export const noPersist = { read: () => null, write: () => { } };
|
|
43
|
+
export function conversationState(persist, continueHours = 24) {
|
|
44
|
+
const v = persist.read();
|
|
45
|
+
if (!v?.messages.length)
|
|
46
|
+
return "none";
|
|
47
|
+
return Date.now() - v.savedAt < continueHours * 60 * 60 * 1000 ? "recent" : "stale";
|
|
48
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@perxel/spotlight/gate`: the gate's headless parts and hooks (Radix dialog, the search engine).
|
|
2
|
+
// Import it only from the gate module the provider loads lazily, so pages never load it until
|
|
3
|
+
// the gate first opens (or is prefetched).
|
|
4
|
+
export { Gate, Marked, useGate, useGateContext } from "./parts.js";
|
|
5
|
+
export { useSearchAsk } from "./useSearchAsk.js";
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type ComponentPropsWithoutRef, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
|
2
|
+
import { type ConversationState } from "../core/storage.js";
|
|
3
|
+
import { type SearchDoc, type SearchIndex, type SearchResult, type Snippet } from "../core/search.js";
|
|
4
|
+
import type { Action } from "../types.js";
|
|
5
|
+
import { type SpotlightState } from "../react/provider.js";
|
|
6
|
+
export interface UseGateOptions {
|
|
7
|
+
open: boolean;
|
|
8
|
+
onOpenChange: (open: boolean) => void;
|
|
9
|
+
}
|
|
10
|
+
/** Everything a gate needs, for a gate built from scratch. `Gate.Root` calls it and shares it with the parts. */
|
|
11
|
+
export declare function useGate({ open, onOpenChange }: UseGateOptions): {
|
|
12
|
+
spotlight: SpotlightState;
|
|
13
|
+
mode: import("../types.js").SpotlightMode;
|
|
14
|
+
open: boolean;
|
|
15
|
+
onOpenChange: (open: boolean) => void;
|
|
16
|
+
copy: import("../types.js").SpotlightCopy;
|
|
17
|
+
query: string;
|
|
18
|
+
setQuery: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
19
|
+
typed: string;
|
|
20
|
+
index: SearchIndex | null;
|
|
21
|
+
conversation: ConversationState;
|
|
22
|
+
focused: boolean;
|
|
23
|
+
inputRef: import("react").RefObject<HTMLInputElement | null>;
|
|
24
|
+
search: {
|
|
25
|
+
results: SearchResult[];
|
|
26
|
+
groups: {
|
|
27
|
+
kind: string;
|
|
28
|
+
results: {
|
|
29
|
+
index: number;
|
|
30
|
+
doc: SearchDoc;
|
|
31
|
+
score: number;
|
|
32
|
+
titleMarks: [start: number, end: number][];
|
|
33
|
+
snippet?: Snippet;
|
|
34
|
+
}[];
|
|
35
|
+
}[];
|
|
36
|
+
open: boolean;
|
|
37
|
+
listed: boolean;
|
|
38
|
+
extras: {
|
|
39
|
+
doc: SearchDoc;
|
|
40
|
+
index: number;
|
|
41
|
+
}[];
|
|
42
|
+
active: number;
|
|
43
|
+
setActive: (i: number) => void;
|
|
44
|
+
onKeyDown: (e: KeyboardEvent<HTMLElement>) => boolean;
|
|
45
|
+
activate: (i: number) => void;
|
|
46
|
+
count: number;
|
|
47
|
+
inputProps: {
|
|
48
|
+
role: "combobox";
|
|
49
|
+
"aria-expanded": boolean;
|
|
50
|
+
"aria-controls": string | undefined;
|
|
51
|
+
"aria-autocomplete": "list";
|
|
52
|
+
"aria-activedescendant": string | undefined;
|
|
53
|
+
} | {
|
|
54
|
+
role?: undefined;
|
|
55
|
+
"aria-expanded"?: undefined;
|
|
56
|
+
"aria-controls"?: undefined;
|
|
57
|
+
"aria-autocomplete"?: undefined;
|
|
58
|
+
"aria-activedescendant"?: undefined;
|
|
59
|
+
};
|
|
60
|
+
listboxProps: {
|
|
61
|
+
id: string;
|
|
62
|
+
role: "listbox";
|
|
63
|
+
};
|
|
64
|
+
optionProps: (i: number) => import("./useSearchAsk.js").SearchAskOption;
|
|
65
|
+
askOptionProps: import("./useSearchAsk.js").SearchAskOption | null;
|
|
66
|
+
};
|
|
67
|
+
ask: (text: string) => void;
|
|
68
|
+
askable: boolean;
|
|
69
|
+
onOpen: (doc: SearchDoc) => void;
|
|
70
|
+
onLinkClick: (e: MouseEvent<HTMLAnchorElement>) => void;
|
|
71
|
+
/** What Enter does, for a hint by the field (`null`: nothing). */
|
|
72
|
+
hint: string | null;
|
|
73
|
+
/** The highlighted result's action, if it is one. */
|
|
74
|
+
activeAction: Action<any> | undefined;
|
|
75
|
+
actionFor: (doc: SearchDoc) => Action<any> | undefined;
|
|
76
|
+
kindLabel: (kind: string) => string;
|
|
77
|
+
/** Spread on the text field. */
|
|
78
|
+
inputProps: {
|
|
79
|
+
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
80
|
+
placeholder: string;
|
|
81
|
+
"aria-label": string;
|
|
82
|
+
maxLength: number;
|
|
83
|
+
enterKeyHint: "search" | "send";
|
|
84
|
+
autoComplete: string;
|
|
85
|
+
autoCorrect: string;
|
|
86
|
+
spellCheck: boolean;
|
|
87
|
+
role: "combobox";
|
|
88
|
+
"aria-expanded": boolean;
|
|
89
|
+
"aria-controls": string | undefined;
|
|
90
|
+
"aria-autocomplete": "list";
|
|
91
|
+
"aria-activedescendant": string | undefined;
|
|
92
|
+
ref: import("react").RefObject<HTMLInputElement | null>;
|
|
93
|
+
value: string;
|
|
94
|
+
onChange: (e: {
|
|
95
|
+
target: {
|
|
96
|
+
value: string;
|
|
97
|
+
};
|
|
98
|
+
}) => void;
|
|
99
|
+
onFocus: () => void;
|
|
100
|
+
onBlur: () => void;
|
|
101
|
+
} | {
|
|
102
|
+
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
103
|
+
placeholder: string;
|
|
104
|
+
"aria-label": string;
|
|
105
|
+
maxLength: number;
|
|
106
|
+
enterKeyHint: "search" | "send";
|
|
107
|
+
autoComplete: string;
|
|
108
|
+
autoCorrect: string;
|
|
109
|
+
spellCheck: boolean;
|
|
110
|
+
role?: undefined;
|
|
111
|
+
"aria-expanded"?: undefined;
|
|
112
|
+
"aria-controls"?: undefined;
|
|
113
|
+
"aria-autocomplete"?: undefined;
|
|
114
|
+
"aria-activedescendant"?: undefined;
|
|
115
|
+
ref: import("react").RefObject<HTMLInputElement | null>;
|
|
116
|
+
value: string;
|
|
117
|
+
onChange: (e: {
|
|
118
|
+
target: {
|
|
119
|
+
value: string;
|
|
120
|
+
};
|
|
121
|
+
}) => void;
|
|
122
|
+
onFocus: () => void;
|
|
123
|
+
onBlur: () => void;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
export type GateApi = ReturnType<typeof useGate>;
|
|
127
|
+
/** The gate's state, inside `Gate.Root`. */
|
|
128
|
+
export declare function useGateContext(): GateApi;
|
|
129
|
+
type DivProps = ComponentPropsWithoutRef<"div">;
|
|
130
|
+
/**
|
|
131
|
+
* The gate: a modal dialog (Radix) with an overlay, the focus moved into the field on open and
|
|
132
|
+
* back where it was on close, Escape clearing a highlighted row before it closes, and a press on
|
|
133
|
+
* the content box itself (the gaps between pieces) closing it like a press outside.
|
|
134
|
+
* State: `data-state` (open/closed), `data-mode`, `data-has-results` on the content.
|
|
135
|
+
*/
|
|
136
|
+
declare function Root({ open, onOpenChange, children, overlayProps, ...props }: UseGateOptions & DivProps & {
|
|
137
|
+
overlayProps?: DivProps;
|
|
138
|
+
children: ReactNode;
|
|
139
|
+
}): import("react").JSX.Element;
|
|
140
|
+
/** The text field. */
|
|
141
|
+
declare function Input(props: ComponentPropsWithoutRef<"input">): import("react").JSX.Element;
|
|
142
|
+
/** The listbox. Renders only while there are results; by default one `Gate.Item` per result. */
|
|
143
|
+
declare function Results({ children, ...props }: Omit<DivProps, "children"> & {
|
|
144
|
+
children?: (result: SearchResult & {
|
|
145
|
+
index: number;
|
|
146
|
+
}) => ReactNode;
|
|
147
|
+
}): import("react").JSX.Element | null;
|
|
148
|
+
/**
|
|
149
|
+
* One result: a link for a document with a `url`, a button for an action. State:
|
|
150
|
+
* `data-highlighted` (the arrow keys' row; also `aria-selected`), `data-kind`. Children default to
|
|
151
|
+
* the marked title and the snippet or subtitle.
|
|
152
|
+
*/
|
|
153
|
+
declare function Item({ result, index, children, ...props }: {
|
|
154
|
+
result: SearchResult;
|
|
155
|
+
index: number;
|
|
156
|
+
children?: ReactNode;
|
|
157
|
+
} & Omit<ComponentPropsWithoutRef<"a">, "children">): import("react").JSX.Element;
|
|
158
|
+
/** Text with marked ranges in `<mark>` (style `mark[data-spotlight-mark]`). */
|
|
159
|
+
export declare function Marked({ text, marks }: {
|
|
160
|
+
text: string;
|
|
161
|
+
marks: Snippet["marks"];
|
|
162
|
+
}): import("react").JSX.Element;
|
|
163
|
+
/** What Enter does, while it does something and the field has focus (`data-on`); children override the text. */
|
|
164
|
+
declare function Hint({ children, ...props }: DivProps): import("react").JSX.Element;
|
|
165
|
+
/** Closes the gate. */
|
|
166
|
+
declare function Close(props: ComponentPropsWithoutRef<"button">): import("react").JSX.Element;
|
|
167
|
+
/** Sends the typed text as a question (a send button for touch screens); only while there is text and Ask is on. */
|
|
168
|
+
declare function Send(props: ComponentPropsWithoutRef<"button">): import("react").JSX.Element | null;
|
|
169
|
+
export declare const Gate: {
|
|
170
|
+
Root: typeof Root;
|
|
171
|
+
Input: typeof Input;
|
|
172
|
+
Results: typeof Results;
|
|
173
|
+
Item: typeof Item;
|
|
174
|
+
Hint: typeof Hint;
|
|
175
|
+
Close: typeof Close;
|
|
176
|
+
Send: typeof Send;
|
|
177
|
+
Marked: typeof Marked;
|
|
178
|
+
};
|
|
179
|
+
export {};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
4
|
+
import { createContext, Fragment, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
5
|
+
import { conversationState } from "../core/storage.js";
|
|
6
|
+
import { mergeCopy } from "../core/copy.js";
|
|
7
|
+
import { buildIndex } from "../core/search.js";
|
|
8
|
+
import { useSpotlight } from "../react/provider.js";
|
|
9
|
+
import { useSearchAsk } from "./useSearchAsk.js";
|
|
10
|
+
const ACTION_PREFIX = "action:";
|
|
11
|
+
/** The action a search document opens, if any (`action:<id>`). */
|
|
12
|
+
function actionFor(doc, actions) {
|
|
13
|
+
if (!doc.id.startsWith(ACTION_PREFIX))
|
|
14
|
+
return undefined;
|
|
15
|
+
const id = doc.id.slice(ACTION_PREFIX.length);
|
|
16
|
+
return actions.find((a) => a.id === id);
|
|
17
|
+
}
|
|
18
|
+
/** Actions with `search` as documents, unless the loaded index already holds one with that id. */
|
|
19
|
+
function actionDocs(actions, docs) {
|
|
20
|
+
const have = new Set(docs.map((d) => d.id));
|
|
21
|
+
return actions
|
|
22
|
+
.filter((a) => a.search && !have.has(ACTION_PREFIX + a.id))
|
|
23
|
+
.map((a) => ({
|
|
24
|
+
id: ACTION_PREFIX + a.id,
|
|
25
|
+
kind: a.search.kind ?? "action",
|
|
26
|
+
title: a.search.title,
|
|
27
|
+
...(a.search.subtitle ? { subtitle: a.search.subtitle } : {}),
|
|
28
|
+
...(a.search.keywords ? { keywords: a.search.keywords } : {}),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
/** The search index, loaded the first time the gate mounts and kept while it stays mounted. */
|
|
32
|
+
function useIndex(s) {
|
|
33
|
+
const [index, setIndex] = useState(null);
|
|
34
|
+
const { search, actions } = s;
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!search)
|
|
37
|
+
return;
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
const got = search.docs ? Promise.resolve(search.docs) : search.load ? search.load() : Promise.resolve([]);
|
|
40
|
+
got
|
|
41
|
+
.then((m) => {
|
|
42
|
+
if (cancelled)
|
|
43
|
+
return;
|
|
44
|
+
const docs = Array.isArray(m) ? m : m.default;
|
|
45
|
+
setIndex(buildIndex([...actionDocs(actions, docs), ...docs]));
|
|
46
|
+
})
|
|
47
|
+
.catch(() => undefined);
|
|
48
|
+
return () => {
|
|
49
|
+
cancelled = true;
|
|
50
|
+
};
|
|
51
|
+
}, [search, actions]);
|
|
52
|
+
return index;
|
|
53
|
+
}
|
|
54
|
+
/** Everything a gate needs, for a gate built from scratch. `Gate.Root` calls it and shares it with the parts. */
|
|
55
|
+
export function useGate({ open, onOpenChange }) {
|
|
56
|
+
const s = useSpotlight();
|
|
57
|
+
const { mode, actions, navigate } = s;
|
|
58
|
+
const copy = useMemo(() => mergeCopy(s.copy), [s.copy]);
|
|
59
|
+
const maxInput = s.ask?.maxInput ?? 1000;
|
|
60
|
+
const [query, setQuery] = useState("");
|
|
61
|
+
const index = useIndex(s);
|
|
62
|
+
const input = useRef(null);
|
|
63
|
+
const [focused, setFocused] = useState(false);
|
|
64
|
+
// Read on every opening: the panel writes the store as the conversation changes.
|
|
65
|
+
const [conversation, setConversation] = useState("none");
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!open)
|
|
68
|
+
return;
|
|
69
|
+
setConversation(s.ask ? conversationState(s.persist, s.ask.continueHours) : "none");
|
|
70
|
+
// A new opening starts empty, like Spotlight.
|
|
71
|
+
setQuery("");
|
|
72
|
+
}, [open]);
|
|
73
|
+
const typed = query.trim();
|
|
74
|
+
const askable = mode === "both" || mode === "ask";
|
|
75
|
+
const ask = (text) => {
|
|
76
|
+
const t = text.trim().slice(0, maxInput);
|
|
77
|
+
if (t && askable)
|
|
78
|
+
s.handOff({ kind: "ask", text: t, fresh: conversation === "stale" });
|
|
79
|
+
};
|
|
80
|
+
const runAction = (action, doc) => {
|
|
81
|
+
const value = action.fromSearch ? action.fromSearch(typed, doc) : undefined;
|
|
82
|
+
if (action.view && s.ask)
|
|
83
|
+
return s.handOff({ kind: "action", id: action.id, input: value });
|
|
84
|
+
onOpenChange(false);
|
|
85
|
+
action.run?.(value);
|
|
86
|
+
};
|
|
87
|
+
const onOpen = (doc) => {
|
|
88
|
+
if (doc.url) {
|
|
89
|
+
// A real click on the row's link, so a site's own link handling (a page transition
|
|
90
|
+
// listening to clicks) runs as for any other link.
|
|
91
|
+
const el = document.getElementById(searchApi.optionProps(searchApi.results.findIndex((r) => r.doc.id === doc.id)).id);
|
|
92
|
+
if (el instanceof HTMLAnchorElement)
|
|
93
|
+
el.click();
|
|
94
|
+
else {
|
|
95
|
+
onOpenChange(false);
|
|
96
|
+
navigate(doc.url, { from: "search" });
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const action = actionFor(doc, actions);
|
|
101
|
+
if (action)
|
|
102
|
+
runAction(action, doc);
|
|
103
|
+
};
|
|
104
|
+
const searchApi = useSearchAsk({
|
|
105
|
+
index,
|
|
106
|
+
query,
|
|
107
|
+
enabled: !!s.search,
|
|
108
|
+
onOpen,
|
|
109
|
+
...(askable ? { onAsk: ask } : {}),
|
|
110
|
+
askRow: false,
|
|
111
|
+
...(s.search?.limit ? { limit: s.search.limit } : {}),
|
|
112
|
+
kindOrder: s.search?.kindOrder ?? [],
|
|
113
|
+
...(s.search?.snippetLength ? { snippetLength: s.search.snippetLength } : {}),
|
|
114
|
+
});
|
|
115
|
+
// What Enter does right now: ask, open the highlighted page, or the highlighted action's hint.
|
|
116
|
+
const active = searchApi.results[searchApi.active];
|
|
117
|
+
const activeAction = active ? actionFor(active.doc, actions) : undefined;
|
|
118
|
+
const hint = !active ? (askable ? copy.hintAsk : searchApi.count ? copy.hintOpen : null) : activeAction?.hint ?? copy.hintOpen;
|
|
119
|
+
/** Result links: modified clicks open a tab as usual; a click a site handler already took closes the gate; else navigate. */
|
|
120
|
+
const onLinkClick = (e) => {
|
|
121
|
+
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
122
|
+
return;
|
|
123
|
+
if (e.defaultPrevented)
|
|
124
|
+
return onOpenChange(false);
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
onOpenChange(false);
|
|
127
|
+
navigate(e.currentTarget.href, { from: "search" });
|
|
128
|
+
};
|
|
129
|
+
const placeholder = mode === "search" ? copy.placeholderSearch : mode === "ask" ? copy.placeholderAsk : copy.placeholder;
|
|
130
|
+
return {
|
|
131
|
+
spotlight: s,
|
|
132
|
+
mode,
|
|
133
|
+
open,
|
|
134
|
+
onOpenChange,
|
|
135
|
+
copy,
|
|
136
|
+
query,
|
|
137
|
+
setQuery,
|
|
138
|
+
typed,
|
|
139
|
+
index,
|
|
140
|
+
conversation,
|
|
141
|
+
focused,
|
|
142
|
+
inputRef: input,
|
|
143
|
+
search: searchApi,
|
|
144
|
+
ask,
|
|
145
|
+
askable,
|
|
146
|
+
onOpen,
|
|
147
|
+
onLinkClick,
|
|
148
|
+
/** What Enter does, for a hint by the field (`null`: nothing). */
|
|
149
|
+
hint,
|
|
150
|
+
/** The highlighted result's action, if it is one. */
|
|
151
|
+
activeAction,
|
|
152
|
+
actionFor: (doc) => actionFor(doc, actions),
|
|
153
|
+
kindLabel: (kind) => copy.kinds[kind] ?? kind,
|
|
154
|
+
/** Spread on the text field. */
|
|
155
|
+
inputProps: {
|
|
156
|
+
ref: input,
|
|
157
|
+
value: query,
|
|
158
|
+
onChange: (e) => setQuery(e.target.value),
|
|
159
|
+
onFocus: () => setFocused(true),
|
|
160
|
+
onBlur: () => setFocused(false),
|
|
161
|
+
...searchApi.inputProps,
|
|
162
|
+
onKeyDown: (e) => {
|
|
163
|
+
if (searchApi.onKeyDown(e))
|
|
164
|
+
return;
|
|
165
|
+
// Typed before the index arrived, or ask-only: Enter asks.
|
|
166
|
+
if (e.key === "Enter" && !e.nativeEvent.isComposing && typed && askable) {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
ask(query);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
placeholder,
|
|
172
|
+
"aria-label": placeholder,
|
|
173
|
+
maxLength: maxInput,
|
|
174
|
+
enterKeyHint: askable ? "send" : "search",
|
|
175
|
+
autoComplete: "off",
|
|
176
|
+
autoCorrect: "off",
|
|
177
|
+
spellCheck: false,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const GateContext = createContext(null);
|
|
182
|
+
/** The gate's state, inside `Gate.Root`. */
|
|
183
|
+
export function useGateContext() {
|
|
184
|
+
const g = useContext(GateContext);
|
|
185
|
+
if (!g)
|
|
186
|
+
throw new Error("Gate parts must be inside <Gate.Root>");
|
|
187
|
+
return g;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The gate: a modal dialog (Radix) with an overlay, the focus moved into the field on open and
|
|
191
|
+
* back where it was on close, Escape clearing a highlighted row before it closes, and a press on
|
|
192
|
+
* the content box itself (the gaps between pieces) closing it like a press outside.
|
|
193
|
+
* State: `data-state` (open/closed), `data-mode`, `data-has-results` on the content.
|
|
194
|
+
*/
|
|
195
|
+
function Root({ open, onOpenChange, children, overlayProps, ...props }) {
|
|
196
|
+
const g = useGate({ open, onOpenChange });
|
|
197
|
+
return (_jsx(GateContext.Provider, { value: g, children: _jsx(Dialog.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(Dialog.Portal, { children: [_jsx(Dialog.Overlay, { "data-spotlight-overlay": "", ...overlayProps }), _jsxs(Dialog.Content, { "data-spotlight-gate": "", "data-mode": g.mode, "data-has-results": g.search.count > 0 || undefined, "aria-describedby": undefined, ...props, onPointerDown: (e) => {
|
|
198
|
+
props.onPointerDown?.(e);
|
|
199
|
+
if (e.target === e.currentTarget)
|
|
200
|
+
onOpenChange(false);
|
|
201
|
+
}, onOpenAutoFocus: (e) => {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
g.inputRef.current?.focus({ preventScroll: true });
|
|
204
|
+
}, onCloseAutoFocus: (e) => {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
g.spotlight.restoreFocus();
|
|
207
|
+
}, onEscapeKeyDown: (e) => {
|
|
208
|
+
if (g.search.active === -1)
|
|
209
|
+
return;
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
g.search.setActive(-1);
|
|
212
|
+
}, children: [_jsx(Dialog.Title, { style: srOnly, children: g.copy.gateTitle }), children, _jsx("p", { style: srOnly, role: "status", "aria-live": "polite", children: g.search.open ? g.copy.results(g.search.count) : "" })] })] }) }) }));
|
|
213
|
+
}
|
|
214
|
+
const srOnly = { position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0,0,0,0)", whiteSpace: "nowrap", border: 0 };
|
|
215
|
+
/** The text field. */
|
|
216
|
+
function Input(props) {
|
|
217
|
+
const g = useGateContext();
|
|
218
|
+
return _jsx("input", { "data-spotlight-input": "", ...g.inputProps, ...props });
|
|
219
|
+
}
|
|
220
|
+
/** The listbox. Renders only while there are results; by default one `Gate.Item` per result. */
|
|
221
|
+
function Results({ children, ...props }) {
|
|
222
|
+
const g = useGateContext();
|
|
223
|
+
if (!g.search.count)
|
|
224
|
+
return null;
|
|
225
|
+
return (_jsx("div", { "data-spotlight-results": "", ...g.search.listboxProps, "aria-label": g.copy.gateTitle, ...props, children: g.search.results.map((r, index) => (children ? _jsx(Fragment, { children: children({ ...r, index }) }, r.doc.id) : _jsx(Item, { result: r, index: index }, r.doc.id))) }));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* One result: a link for a document with a `url`, a button for an action. State:
|
|
229
|
+
* `data-highlighted` (the arrow keys' row; also `aria-selected`), `data-kind`. Children default to
|
|
230
|
+
* the marked title and the snippet or subtitle.
|
|
231
|
+
*/
|
|
232
|
+
function Item({ result, index, children, ...props }) {
|
|
233
|
+
const g = useGateContext();
|
|
234
|
+
const option = g.search.optionProps(index);
|
|
235
|
+
const shared = {
|
|
236
|
+
...option,
|
|
237
|
+
"data-spotlight-item": "",
|
|
238
|
+
"data-kind": result.doc.kind,
|
|
239
|
+
"data-highlighted": option["aria-selected"] || undefined,
|
|
240
|
+
};
|
|
241
|
+
const body = children ?? (_jsxs(_Fragment, { children: [_jsx("span", { "data-spotlight-title": "", children: _jsx(Marked, { text: result.doc.title, marks: result.titleMarks }) }), result.snippet ? (_jsx("span", { "data-spotlight-snippet": "", children: _jsx(Marked, { ...result.snippet }) })) : result.doc.subtitle ? (_jsx("span", { "data-spotlight-subtitle": "", children: result.doc.subtitle })) : null] }));
|
|
242
|
+
if (result.doc.url) {
|
|
243
|
+
return (_jsx("a", { href: result.doc.url, ...shared, ...props, onClick: (e) => {
|
|
244
|
+
props.onClick?.(e);
|
|
245
|
+
g.onLinkClick(e);
|
|
246
|
+
}, children: body }));
|
|
247
|
+
}
|
|
248
|
+
const { className, style } = props;
|
|
249
|
+
return (_jsx("button", { type: "button", ...shared, className: className, style: style, onClick: () => g.search.activate(index), children: body }));
|
|
250
|
+
}
|
|
251
|
+
/** Text with marked ranges in `<mark>` (style `mark[data-spotlight-mark]`). */
|
|
252
|
+
export function Marked({ text, marks }) {
|
|
253
|
+
const parts = [];
|
|
254
|
+
let at = 0;
|
|
255
|
+
for (const [a, b] of marks) {
|
|
256
|
+
if (a > at)
|
|
257
|
+
parts.push(text.slice(at, a));
|
|
258
|
+
parts.push(_jsx("mark", { "data-spotlight-mark": "", children: text.slice(a, b) }, a));
|
|
259
|
+
at = b;
|
|
260
|
+
}
|
|
261
|
+
parts.push(text.slice(at));
|
|
262
|
+
return _jsx(_Fragment, { children: parts });
|
|
263
|
+
}
|
|
264
|
+
/** What Enter does, while it does something and the field has focus (`data-on`); children override the text. */
|
|
265
|
+
function Hint({ children, ...props }) {
|
|
266
|
+
const g = useGateContext();
|
|
267
|
+
const on = g.focused && !!g.typed && !!g.hint;
|
|
268
|
+
return _jsx("span", { "data-spotlight-hint": "", "data-on": on || undefined, "aria-hidden": true, ...props, children: children ?? g.hint });
|
|
269
|
+
}
|
|
270
|
+
/** Closes the gate. */
|
|
271
|
+
function Close(props) {
|
|
272
|
+
const g = useGateContext();
|
|
273
|
+
return _jsx(Dialog.Close, { "aria-label": g.copy.close, "data-spotlight-close": "", ...props });
|
|
274
|
+
}
|
|
275
|
+
/** Sends the typed text as a question (a send button for touch screens); only while there is text and Ask is on. */
|
|
276
|
+
function Send(props) {
|
|
277
|
+
const g = useGateContext();
|
|
278
|
+
if (!g.typed || !g.askable)
|
|
279
|
+
return null;
|
|
280
|
+
return (_jsx("button", { type: "button", "aria-label": g.copy.send, "data-spotlight-send": "", ...props, onMouseDown: (e) => e.preventDefault(), onClick: () => g.ask(g.query) }));
|
|
281
|
+
}
|
|
282
|
+
export const Gate = { Root, Input, Results, Item, Hint, Close, Send, Marked };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type KeyboardEvent } from "react";
|
|
2
|
+
import { type SearchDoc, type SearchIndex, type SearchResult } from "../core/search.js";
|
|
3
|
+
export interface UseSearchAskOptions {
|
|
4
|
+
index: SearchIndex | null;
|
|
5
|
+
/** The field's current text. The host owns it (it is usually also the chat composer's value). */
|
|
6
|
+
query: string;
|
|
7
|
+
/** Off: no results, no key handling, plain field props (e.g. once a conversation has started). */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
/** Open a result: navigate to `doc.url`, or run the host's own action for docs without one. */
|
|
10
|
+
onOpen: (doc: SearchDoc) => void;
|
|
11
|
+
/**
|
|
12
|
+
* Send the text as a question. With it, Enter asks unless a result is highlighted, and the
|
|
13
|
+
* last option is the ask row. Without it (plain site search), Enter opens the first result.
|
|
14
|
+
*/
|
|
15
|
+
onAsk?: (query: string) => void;
|
|
16
|
+
/** With `onAsk`: list the ask row after the results (default). Off, Enter still asks; the host shows the hint itself. */
|
|
17
|
+
askRow?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Rows listed after the results and the ask row, walked by the same arrow keys and opened with
|
|
20
|
+
* `onOpen` (e.g. a welcome list while nothing is typed or nothing matches). Shown on an empty
|
|
21
|
+
* query too. The host decides when to pass them.
|
|
22
|
+
*/
|
|
23
|
+
extras?: SearchDoc[];
|
|
24
|
+
limit?: number;
|
|
25
|
+
kindOrder?: string[];
|
|
26
|
+
snippetLength?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface SearchAskOption {
|
|
29
|
+
id: string;
|
|
30
|
+
role: "option";
|
|
31
|
+
"aria-selected": boolean;
|
|
32
|
+
/** Keeps the focus in the field when a row is clicked. */
|
|
33
|
+
onMouseDown: (e: {
|
|
34
|
+
preventDefault: () => void;
|
|
35
|
+
}) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare function useSearchAsk({ index, query, enabled, onOpen, onAsk, extras, askRow, limit, kindOrder, snippetLength }: UseSearchAskOptions): {
|
|
38
|
+
/** Results in display order, and the same grouped by kind (each with its flat `index`). */
|
|
39
|
+
results: SearchResult[];
|
|
40
|
+
groups: {
|
|
41
|
+
kind: string;
|
|
42
|
+
results: {
|
|
43
|
+
index: number;
|
|
44
|
+
doc: SearchDoc;
|
|
45
|
+
score: number;
|
|
46
|
+
titleMarks: [start: number, end: number][];
|
|
47
|
+
snippet?: import("../core/search.js").Snippet;
|
|
48
|
+
}[];
|
|
49
|
+
}[];
|
|
50
|
+
/** True while text is typed and searched (results, maybe none, and the ask row). */
|
|
51
|
+
open: boolean;
|
|
52
|
+
/** True while the list has any row (results, the ask row or extras). */
|
|
53
|
+
listed: boolean;
|
|
54
|
+
/** The extras, each with its flat `index` (for `optionProps`). */
|
|
55
|
+
extras: {
|
|
56
|
+
doc: SearchDoc;
|
|
57
|
+
index: number;
|
|
58
|
+
}[];
|
|
59
|
+
/** The highlighted option, -1 for none. The ask row is `results.length`. */
|
|
60
|
+
active: number;
|
|
61
|
+
setActive: (i: number) => void;
|
|
62
|
+
onKeyDown: (e: KeyboardEvent<HTMLElement>) => boolean;
|
|
63
|
+
/** Open result `i` or, at `results.length`, ask. For row clicks. */
|
|
64
|
+
activate: (i: number) => void;
|
|
65
|
+
/** Number of results, for the host's live region ("3 results"). */
|
|
66
|
+
count: number;
|
|
67
|
+
/** Spread on the text field (combobox pattern; the focus never leaves it). Empty while disabled. */
|
|
68
|
+
inputProps: {
|
|
69
|
+
role: "combobox";
|
|
70
|
+
"aria-expanded": boolean;
|
|
71
|
+
"aria-controls": string | undefined;
|
|
72
|
+
"aria-autocomplete": "list";
|
|
73
|
+
"aria-activedescendant": string | undefined;
|
|
74
|
+
} | {
|
|
75
|
+
role?: undefined;
|
|
76
|
+
"aria-expanded"?: undefined;
|
|
77
|
+
"aria-controls"?: undefined;
|
|
78
|
+
"aria-autocomplete"?: undefined;
|
|
79
|
+
"aria-activedescendant"?: undefined;
|
|
80
|
+
};
|
|
81
|
+
/** Spread on the element that holds every option. */
|
|
82
|
+
listboxProps: {
|
|
83
|
+
id: string;
|
|
84
|
+
role: "listbox";
|
|
85
|
+
};
|
|
86
|
+
/** Spread on result row `i` (its flat index). */
|
|
87
|
+
optionProps: (i: number) => SearchAskOption;
|
|
88
|
+
/** Spread on the ask row; null without `onAsk`. */
|
|
89
|
+
askOptionProps: SearchAskOption | null;
|
|
90
|
+
};
|