@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,8 @@
|
|
|
1
|
+
import "./liquid.css";
|
|
2
|
+
import type { PanelProps } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The liquid panel: a floating card on desktop (non-modal: the page stays usable), a bottom sheet
|
|
5
|
+
* on phones (dim backdrop, swipe down to close, the page behind does not scroll). The header and
|
|
6
|
+
* composer float over the conversation as glass; it scrolls under them.
|
|
7
|
+
*/
|
|
8
|
+
export default function LiquidPanel({ open, onOpenChange }: PanelProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import "./liquid.css";
|
|
4
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
5
|
+
import { Suspense, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { Chat } from "../chat/parts.js";
|
|
7
|
+
import { useAskChat } from "../chat/useAskChat.js";
|
|
8
|
+
import { useSpotlight } from "../react/provider.js";
|
|
9
|
+
import { useLiquid } from "./context.js";
|
|
10
|
+
import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronDownIcon, FileTextIcon, SquareIcon, SquarePenIcon, XIcon } from "./icons.js";
|
|
11
|
+
import { cx, PHONE_QUERY, useMedia } from "./util.js";
|
|
12
|
+
/**
|
|
13
|
+
* The liquid panel: a floating card on desktop (non-modal: the page stays usable), a bottom sheet
|
|
14
|
+
* on phones (dim backdrop, swipe down to close, the page behind does not scroll). The header and
|
|
15
|
+
* composer float over the conversation as glass; it scrolls under them.
|
|
16
|
+
*/
|
|
17
|
+
export default function LiquidPanel({ open, onOpenChange }) {
|
|
18
|
+
const liquid = useLiquid();
|
|
19
|
+
const s = useSpotlight();
|
|
20
|
+
const chat = useAskChat({ uiTools: liquid.uiTools ?? [] });
|
|
21
|
+
const { copy, view } = chat;
|
|
22
|
+
const phone = useMedia(PHONE_QUERY);
|
|
23
|
+
const composer = useRef(null);
|
|
24
|
+
// Desktop: ready for a follow-up after a question or "show" from the gate (also when the panel
|
|
25
|
+
// was already open, so no open focus runs).
|
|
26
|
+
const req = s.request;
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!req || phone)
|
|
29
|
+
return;
|
|
30
|
+
if (req.r.kind === "ask" || req.r.kind === "show")
|
|
31
|
+
requestAnimationFrame(() => composer.current?.focus({ preventScroll: true }));
|
|
32
|
+
}, [req?.n]);
|
|
33
|
+
// Links in answers and sources: in-app ones navigate without a page load; phones close the sheet
|
|
34
|
+
// (it covers the page), the conversation stays. Streamdown opens links in a new tab: overridden.
|
|
35
|
+
const local = new Set([...(liquid.localHosts ?? [])]);
|
|
36
|
+
const onLinkClick = (e) => {
|
|
37
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
38
|
+
return;
|
|
39
|
+
const a = e.target.closest?.("a[href]");
|
|
40
|
+
if (!a || a.hasAttribute("download"))
|
|
41
|
+
return;
|
|
42
|
+
if (a.target && a.target !== "_self" && a.dataset.streamdown !== "link")
|
|
43
|
+
return;
|
|
44
|
+
const url = new URL(a.href, location.href);
|
|
45
|
+
if (url.origin !== location.origin && !local.has(url.hostname))
|
|
46
|
+
return;
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
s.navigate(url.pathname + url.search + url.hash, { from: "answer" });
|
|
49
|
+
if (phone)
|
|
50
|
+
onOpenChange(false);
|
|
51
|
+
};
|
|
52
|
+
const localPath = (href) => {
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(href);
|
|
55
|
+
return local.has(u.hostname) ? u.pathname + u.search + u.hash : href;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return href;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const viewport = usePhoneViewport(open && phone);
|
|
62
|
+
const sheet = useRef(null);
|
|
63
|
+
const drag = useSheetDrag(sheet, () => onOpenChange(false), open && phone);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!open || !phone)
|
|
66
|
+
return;
|
|
67
|
+
const html = document.documentElement;
|
|
68
|
+
const prev = html.style.overflow;
|
|
69
|
+
html.style.overflow = "hidden";
|
|
70
|
+
return () => {
|
|
71
|
+
html.style.overflow = prev;
|
|
72
|
+
};
|
|
73
|
+
}, [open, phone]);
|
|
74
|
+
// Phones: as tall as the visible screen allows; with the keyboard up, all of what is left.
|
|
75
|
+
const sheetStyle = viewport
|
|
76
|
+
? (() => {
|
|
77
|
+
const keyboard = viewport.height < window.innerHeight * 0.75;
|
|
78
|
+
const height = keyboard ? viewport.height : Math.round(viewport.height * 0.92);
|
|
79
|
+
return { top: viewport.top + viewport.height - height, height };
|
|
80
|
+
})()
|
|
81
|
+
: undefined;
|
|
82
|
+
const [head, setHead] = useState(null);
|
|
83
|
+
const [foot, setFoot] = useState(null);
|
|
84
|
+
useOverlayInsets(head, foot);
|
|
85
|
+
const title = view.kind === "action" ? view.action.title ?? copy.panelTitle : copy.panelTitle;
|
|
86
|
+
const handoff = liquid.handoff?.(chat);
|
|
87
|
+
const ActionView = view.kind === "action" ? view.action.view : undefined;
|
|
88
|
+
return (_jsx(Dialog.Root, { open: open, onOpenChange: onOpenChange, modal: false, children: _jsxs(Dialog.Portal, { children: [phone && _jsx("div", { "aria-hidden": true, onClick: () => onOpenChange(false), "data-state": open ? "open" : "closed", className: cx("sp-backdrop", liquid.className) }), _jsxs(Dialog.Content, { ref: sheet, "data-spotlight-panel": "", "data-status": chat.status, "data-view": view.kind, "aria-describedby": undefined, ...liquid.panelProps, className: cx("sp-panel", liquid.className, liquid.panelProps?.className), style: sheetStyle, onClickCapture: onLinkClick, onInteractOutside: (e) => e.preventDefault(), onOpenAutoFocus: (e) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
// Desktop: the cursor goes to the composer. Phones: nothing, so the keyboard does not cover the answer.
|
|
91
|
+
if (!phone)
|
|
92
|
+
composer.current?.focus({ preventScroll: true });
|
|
93
|
+
}, children: [_jsxs("div", { ref: setHead, ...drag, className: "sp-panel-head", children: [_jsx("div", { "aria-hidden": true, className: "sp-grab" }), _jsxs("header", { className: "sp-liquid sp-panel-pill", children: [view.kind !== "chat" && (_jsx("button", { type: "button", onClick: chat.back, className: "sp-icon-button sp-back", "aria-label": copy.back, title: copy.back, children: _jsx(ArrowLeftIcon, {}) })), _jsxs(Dialog.Title, { className: "sp-panel-title", children: [view.kind === "chat" && liquid.logo && _jsx("span", { "aria-hidden": true, className: "sp-logo", children: liquid.logo }), _jsx("span", { className: "sp-truncate", children: title })] }), view.kind === "chat" && chat.messages.length > 0 && (_jsx("button", { type: "button", onClick: chat.newChat, className: "sp-icon-button", "aria-label": copy.newChat, title: copy.newChat, children: _jsx(SquarePenIcon, {}) })), _jsx(Dialog.Close, { className: "sp-icon-button", "aria-label": copy.close, children: _jsx(XIcon, {}) })] })] }), ActionView && view.kind === "action" ? (_jsx("div", { className: "sp-view", "data-lenis-prevent": "", children: _jsx(Suspense, { fallback: null, children: _jsx(ActionView, { input: view.input, messages: chat.messages, conversationId: chat.messages.length ? chat.id : null, back: chat.back, close: chat.close }) }) }, view.key)) : (_jsxs(_Fragment, { children: [_jsxs(Chat.Messages, { className: "sp-messages", children: [_jsxs(Chat.Content, { className: "sp-messages-content", viewportProps: { className: "sp-messages-viewport sp-scroll" }, children: [liquid.privacyNote && _jsx("div", { className: "sp-privacy", children: _jsx(Suspense, { fallback: null, children: liquid.privacyNote }) }), chat.messages.map((m, i) => {
|
|
94
|
+
const text = chat.textOf(m);
|
|
95
|
+
const sources = m.role === "assistant" ? chat.sourcesOf(m) : [];
|
|
96
|
+
const action = chat.actionOf(m);
|
|
97
|
+
if (!text && !sources.length && !action)
|
|
98
|
+
return null;
|
|
99
|
+
return (_jsxs("div", { "data-role": m.role, className: cx("sp-message", m.role === "user" && i > 0 && "sp-message-gap"), children: [text && (m.role === "assistant" ? _jsx("div", { className: "sp-answer", children: _jsx(Chat.Markdown, { text: text, className: "sp-md" }) }) : _jsx("p", { className: "sp-bubble", children: text })), sources.length > 0 && (_jsxs("details", { className: "sp-sources", children: [_jsxs("summary", { children: [copy.sources, " (", sources.length, ")", _jsx(ChevronDownIcon, { className: "sp-chevron" })] }), _jsx("div", { className: "sp-sources-list", children: sources.map((src) => (_jsxs("a", { href: localPath(src.url), children: [_jsx(FileTextIcon, {}), _jsx("span", { children: src.title })] }, src.url))) })] })), action && (_jsxs("button", { type: "button", className: "sp-action-row", onClick: () => chat.openAction(action.action, action.input), children: [action.action.label ?? action.action.title ?? action.action.id, _jsx(ArrowUpRightIcon, {})] }))] }, m.id));
|
|
100
|
+
}), chat.waiting && (_jsxs("div", { className: "sp-waiting", role: "status", children: [liquid.logo && _jsx("span", { "aria-hidden": true, className: "sp-logo sp-pulse", children: liquid.logo }), copy.thinking] })), chat.errorCode && !chat.busy && (_jsx("p", { className: "sp-error", role: "alert", children: copy.errors[chat.errorCode] ?? copy.errors.generic })), !chat.busy && handoff && _jsx(Suspense, { fallback: null, children: handoff })] }), _jsx(Chat.ScrollButton, { className: "sp-scroll-button", "aria-label": copy.scrollToEnd, children: _jsx(ArrowDownIcon, {}) })] }), _jsxs(Chat.Composer, { chat: chat, ref: setFoot, className: "sp-panel-foot", textareaProps: { ref: composer, className: "sp-textarea" }, children: [liquid.tide && _jsxs("div", { "aria-hidden": true, className: "sp-tide", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }), _jsxs("div", { className: "sp-composer-row", children: [_jsx(Suspense, { fallback: _jsx("span", {}), children: liquid.composerExtra?.(chat) ?? _jsx("span", {}) }), _jsx(Chat.Submit, { chat: chat, className: "sp-fill sp-submit", send: _jsx(ArrowUpIcon, {}), stop: _jsx(SquareIcon, { className: "sp-stop-icon" }) })] })] })] }))] })] }) }));
|
|
101
|
+
}
|
|
102
|
+
/** Writes the floating header's and composer's heights to `--sp-top` / `--sp-bottom` on the panel, so the content rests clear of them. */
|
|
103
|
+
function useOverlayInsets(head, foot) {
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const panel = (head ?? foot)?.closest(".sp-panel");
|
|
106
|
+
if (!panel)
|
|
107
|
+
return;
|
|
108
|
+
const write = () => {
|
|
109
|
+
panel.style.setProperty("--sp-top", `${head?.offsetHeight ?? 0}px`);
|
|
110
|
+
panel.style.setProperty("--sp-bottom", `${foot?.offsetHeight ?? 0}px`);
|
|
111
|
+
};
|
|
112
|
+
write();
|
|
113
|
+
const ro = new ResizeObserver(write);
|
|
114
|
+
if (head)
|
|
115
|
+
ro.observe(head);
|
|
116
|
+
if (foot)
|
|
117
|
+
ro.observe(foot);
|
|
118
|
+
return () => ro.disconnect();
|
|
119
|
+
}, [head, foot]);
|
|
120
|
+
}
|
|
121
|
+
/** The visual viewport while `active` (phones with the panel open): it shrinks when the keyboard opens, which `svh`/`dvh` do not. */
|
|
122
|
+
function usePhoneViewport(active) {
|
|
123
|
+
const [vp, setVp] = useState(null);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const vv = window.visualViewport;
|
|
126
|
+
if (!active || !vv) {
|
|
127
|
+
setVp(null);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const update = () => setVp({ top: vv.offsetTop, height: vv.height });
|
|
131
|
+
update();
|
|
132
|
+
vv.addEventListener("resize", update);
|
|
133
|
+
vv.addEventListener("scroll", update);
|
|
134
|
+
return () => {
|
|
135
|
+
vv.removeEventListener("resize", update);
|
|
136
|
+
vv.removeEventListener("scroll", update);
|
|
137
|
+
};
|
|
138
|
+
}, [active]);
|
|
139
|
+
return vp;
|
|
140
|
+
}
|
|
141
|
+
/** Swipe-down-to-close for the phone sheet: pointer handlers for its grab area. */
|
|
142
|
+
function useSheetDrag(sheet, close, enabled) {
|
|
143
|
+
const start = useRef(null);
|
|
144
|
+
const dy = useRef(0);
|
|
145
|
+
const set = (y, animate) => {
|
|
146
|
+
const el = sheet.current;
|
|
147
|
+
if (!el)
|
|
148
|
+
return;
|
|
149
|
+
el.style.transition = animate ? "transform 200ms ease-out" : "none";
|
|
150
|
+
el.style.transform = y ? `translateY(${y}px)` : "";
|
|
151
|
+
};
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (enabled)
|
|
154
|
+
set(0, false);
|
|
155
|
+
}, [enabled]);
|
|
156
|
+
if (!enabled)
|
|
157
|
+
return {};
|
|
158
|
+
return {
|
|
159
|
+
onPointerDown: (e) => {
|
|
160
|
+
if (e.target.closest("button"))
|
|
161
|
+
return;
|
|
162
|
+
start.current = e.clientY;
|
|
163
|
+
dy.current = 0;
|
|
164
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
165
|
+
},
|
|
166
|
+
onPointerMove: (e) => {
|
|
167
|
+
if (start.current === null)
|
|
168
|
+
return;
|
|
169
|
+
dy.current = Math.max(0, e.clientY - start.current);
|
|
170
|
+
set(dy.current, false);
|
|
171
|
+
},
|
|
172
|
+
onPointerUp: () => {
|
|
173
|
+
if (start.current === null)
|
|
174
|
+
return;
|
|
175
|
+
start.current = null;
|
|
176
|
+
if (dy.current > 80)
|
|
177
|
+
close();
|
|
178
|
+
else
|
|
179
|
+
set(0, true);
|
|
180
|
+
},
|
|
181
|
+
onPointerCancel: () => {
|
|
182
|
+
start.current = null;
|
|
183
|
+
set(0, true);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Below this the panel is a bottom sheet and the gate sits near the top edge (matches the CSS). */
|
|
2
|
+
export declare const PHONE_QUERY = "(max-width: 1023.98px)";
|
|
3
|
+
export declare const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
|
4
|
+
export declare function cx(...parts: (string | false | null | undefined)[]): string;
|
|
5
|
+
/** A media query, `false` on the server and during hydration. */
|
|
6
|
+
export declare function useMedia(query: string): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSyncExternalStore } from "react";
|
|
3
|
+
/** Below this the panel is a bottom sheet and the gate sits near the top edge (matches the CSS). */
|
|
4
|
+
export const PHONE_QUERY = "(max-width: 1023.98px)";
|
|
5
|
+
export const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
|
6
|
+
export function cx(...parts) {
|
|
7
|
+
return parts.filter(Boolean).join(" ");
|
|
8
|
+
}
|
|
9
|
+
/** A media query, `false` on the server and during hydration. */
|
|
10
|
+
export function useMedia(query) {
|
|
11
|
+
return useSyncExternalStore((cb) => {
|
|
12
|
+
const m = matchMedia(query);
|
|
13
|
+
m.addEventListener("change", cb);
|
|
14
|
+
return () => m.removeEventListener("change", cb);
|
|
15
|
+
}, () => matchMedia(query).matches, () => false);
|
|
16
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { SpotlightProvider, Trigger, useSpotlight, isMac, shortcutLabel, defaultNavigate, type SpotlightState } from "./provider.js";
|
|
2
|
+
export { localStorageAdapter, noPersist } from "../core/storage.js";
|
|
3
|
+
export { fold, emailIn } from "../core/fold.js";
|
|
4
|
+
export { turnstileWidget, type TurnstileWidget, type TurnstileWidgetOptions } from "./turnstile.js";
|
|
5
|
+
export { defineAction } from "../types.js";
|
|
6
|
+
export type * from "../types.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `@perxel/spotlight/react`: what a page's first load needs, and nothing more. The provider (state,
|
|
2
|
+
// the shortcut, lazy loading of the gate and panel modules, the iOS keyboard bridge), a trigger,
|
|
3
|
+
// `useSpotlight`. No styling: state is in `data-*` attributes. The gate's parts are in
|
|
4
|
+
// `@perxel/spotlight/gate` and the chat's in `@perxel/spotlight/chat`: import those only from the
|
|
5
|
+
// lazily loaded gate and panel modules.
|
|
6
|
+
export { SpotlightProvider, Trigger, useSpotlight, isMac, shortcutLabel, defaultNavigate } from "./provider.js";
|
|
7
|
+
export { localStorageAdapter, noPersist } from "../core/storage.js";
|
|
8
|
+
export { fold, emailIn } from "../core/fold.js";
|
|
9
|
+
export { turnstileWidget } from "./turnstile.js";
|
|
10
|
+
export { defineAction } from "../types.js";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import type { Action, AskConfig, Navigate, PersistAdapter, SearchConfig, SpotlightCopy, SpotlightMode, SpotlightProps, SpotlightRequest } from "../types.js";
|
|
3
|
+
/** Everything the gate, the panel and the triggers read. */
|
|
4
|
+
export interface SpotlightState {
|
|
5
|
+
mode: SpotlightMode;
|
|
6
|
+
/** On and with something to show: triggers render, the shortcut is bound. */
|
|
7
|
+
available: boolean;
|
|
8
|
+
search: SearchConfig | undefined;
|
|
9
|
+
ask: AskConfig | undefined;
|
|
10
|
+
actions: Action[];
|
|
11
|
+
/** The site's copy overrides; the gate and the panel merge them with the English defaults (`mergeCopy`), so the defaults stay out of first load. */
|
|
12
|
+
copy: Partial<SpotlightCopy>;
|
|
13
|
+
navigate: Navigate;
|
|
14
|
+
/** Where the conversation is kept (`ask.persist`, default `localStorage`). */
|
|
15
|
+
persist: PersistAdapter;
|
|
16
|
+
gateOpen: boolean;
|
|
17
|
+
/** Open the gate. Call it inside the click or key handler: iOS opens the keyboard only inside the gesture. */
|
|
18
|
+
openGate: () => void;
|
|
19
|
+
closeGate: () => void;
|
|
20
|
+
setGateOpen: (open: boolean) => void;
|
|
21
|
+
panelOpen: boolean;
|
|
22
|
+
setPanelOpen: (open: boolean) => void;
|
|
23
|
+
/** Start downloading the gate (pointer enter, focus, touch start on a trigger). */
|
|
24
|
+
prefetch: () => void;
|
|
25
|
+
/** From the gate: close it and give the panel its job. */
|
|
26
|
+
handOff: (request: SpotlightRequest) => void;
|
|
27
|
+
/** The panel's current job; `n` changes with every new one. */
|
|
28
|
+
request: {
|
|
29
|
+
n: number;
|
|
30
|
+
r: SpotlightRequest;
|
|
31
|
+
} | null;
|
|
32
|
+
/** The gate closed by itself (Escape, outside, the shortcut): focus goes back where it was. */
|
|
33
|
+
restoreFocus: () => void;
|
|
34
|
+
}
|
|
35
|
+
/** The provider's state. Outside a provider: an unavailable stub, so a trigger renders nothing. */
|
|
36
|
+
export declare function useSpotlight(): SpotlightState;
|
|
37
|
+
/** Apple keyboard: the shortcut is ⌘ there, Ctrl elsewhere. Client only. */
|
|
38
|
+
export declare function isMac(): boolean;
|
|
39
|
+
/** The shortcut as shown to the visitor ("⌘K", "Ctrl K"); `null` when off. Client only. */
|
|
40
|
+
export declare function shortcutLabel(shortcut?: string | false): string | null;
|
|
41
|
+
/** Default navigation: a hash on this page scrolls; another site opens in a new tab; anything else is a page load. */
|
|
42
|
+
export declare const defaultNavigate: Navigate;
|
|
43
|
+
export declare function SpotlightProvider({ search, ask, actions, gate, panel, navigate, shortcut, enabled, copy, prefetch: prefetchWhen, children, }: SpotlightProps): import("react").JSX.Element;
|
|
44
|
+
/**
|
|
45
|
+
* A headless trigger: a `<button>` that opens the gate and prefetches it on pointer enter, focus
|
|
46
|
+
* and touch. Renders nothing while Spotlight is unavailable, unless `always` (for a trigger that
|
|
47
|
+
* must be in the server HTML, so the layout never shifts).
|
|
48
|
+
*/
|
|
49
|
+
export declare function Trigger({ children, always, onClick, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
50
|
+
always?: boolean;
|
|
51
|
+
children?: ReactNode;
|
|
52
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { localStorageAdapter } from "../core/storage.js";
|
|
5
|
+
const SpotlightContext = createContext(null);
|
|
6
|
+
/** The provider's state. Outside a provider: an unavailable stub, so a trigger renders nothing. */
|
|
7
|
+
export function useSpotlight() {
|
|
8
|
+
return useContext(SpotlightContext) ?? STUB;
|
|
9
|
+
}
|
|
10
|
+
const noop = () => { };
|
|
11
|
+
const STUB = {
|
|
12
|
+
mode: "none",
|
|
13
|
+
available: false,
|
|
14
|
+
search: undefined,
|
|
15
|
+
ask: undefined,
|
|
16
|
+
actions: [],
|
|
17
|
+
copy: {},
|
|
18
|
+
navigate: noop,
|
|
19
|
+
persist: { read: () => null, write: noop },
|
|
20
|
+
gateOpen: false,
|
|
21
|
+
openGate: noop,
|
|
22
|
+
closeGate: noop,
|
|
23
|
+
setGateOpen: noop,
|
|
24
|
+
panelOpen: false,
|
|
25
|
+
setPanelOpen: noop,
|
|
26
|
+
prefetch: noop,
|
|
27
|
+
handOff: noop,
|
|
28
|
+
request: null,
|
|
29
|
+
restoreFocus: noop,
|
|
30
|
+
};
|
|
31
|
+
/** Apple keyboard: the shortcut is ⌘ there, Ctrl elsewhere. Client only. */
|
|
32
|
+
export function isMac() {
|
|
33
|
+
const nav = navigator;
|
|
34
|
+
return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform || nav.platform || nav.userAgent);
|
|
35
|
+
}
|
|
36
|
+
/** The shortcut as shown to the visitor ("⌘K", "Ctrl K"); `null` when off. Client only. */
|
|
37
|
+
export function shortcutLabel(shortcut = "mod+k") {
|
|
38
|
+
if (!shortcut)
|
|
39
|
+
return null;
|
|
40
|
+
const key = shortcut.split("+").at(-1).toUpperCase();
|
|
41
|
+
return isMac() ? `⌘${key}` : `Ctrl ${key}`;
|
|
42
|
+
}
|
|
43
|
+
/** Default navigation: a hash on this page scrolls; another site opens in a new tab; anything else is a page load. */
|
|
44
|
+
export const defaultNavigate = (url, { newTab }) => {
|
|
45
|
+
if (newTab || new URL(url, location.href).origin !== location.origin) {
|
|
46
|
+
window.open(url, "_blank", "noopener");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const u = new URL(url, location.href);
|
|
50
|
+
if (u.origin === location.origin && u.pathname === location.pathname && u.search === location.search && u.hash) {
|
|
51
|
+
location.hash = u.hash;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
location.assign(u.href);
|
|
55
|
+
};
|
|
56
|
+
function useLazy(loader) {
|
|
57
|
+
// One lazy component per loader: pass a stable (module-level) loader.
|
|
58
|
+
return useMemo(() => (loader ? lazy(loader) : null), [loader]);
|
|
59
|
+
}
|
|
60
|
+
/** True once the page is idle after load (`delay` ms), or at the first input, whichever comes first. */
|
|
61
|
+
function useIdle(delay) {
|
|
62
|
+
const [idle, setIdle] = useState(false);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (delay === false)
|
|
65
|
+
return;
|
|
66
|
+
let timer;
|
|
67
|
+
const wake = () => setIdle(true);
|
|
68
|
+
const events = ["pointerdown", "keydown", "wheel", "touchstart", "scroll"];
|
|
69
|
+
const start = () => {
|
|
70
|
+
timer = setTimeout(wake, delay);
|
|
71
|
+
};
|
|
72
|
+
if (document.readyState === "complete")
|
|
73
|
+
start();
|
|
74
|
+
else
|
|
75
|
+
window.addEventListener("load", start, { once: true });
|
|
76
|
+
for (const e of events)
|
|
77
|
+
window.addEventListener(e, wake, { once: true, passive: true });
|
|
78
|
+
return () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
window.removeEventListener("load", start);
|
|
81
|
+
for (const e of events)
|
|
82
|
+
window.removeEventListener(e, wake);
|
|
83
|
+
};
|
|
84
|
+
}, [delay]);
|
|
85
|
+
return idle;
|
|
86
|
+
}
|
|
87
|
+
export function SpotlightProvider({ search, ask, actions = [], gate, panel, navigate = defaultNavigate, shortcut = "mod+k", enabled = true, copy, prefetch: prefetchWhen = true, children, }) {
|
|
88
|
+
const mode = search && ask ? "both" : search ? "search" : ask ? "ask" : "none";
|
|
89
|
+
const available = enabled && mode !== "none";
|
|
90
|
+
const merged = useMemo(() => copy ?? {}, [copy]);
|
|
91
|
+
const persist = useMemo(() => (ask?.persist === false ? { read: () => null, write: () => { } } : ask?.persist ?? localStorageAdapter()), [ask?.persist]);
|
|
92
|
+
if (ask && !panel && typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
93
|
+
console.warn("[spotlight] `ask` is set but no `panel` module: questions have nowhere to go. Pass `panel` (or use a skin).");
|
|
94
|
+
}
|
|
95
|
+
const Gate = useLazy(gate);
|
|
96
|
+
const Panel = useLazy(ask ? panel : undefined);
|
|
97
|
+
const [panelOpen, setPanelOpenRaw] = useState(false);
|
|
98
|
+
// Once opened, the panel stays mounted (hidden when closed) so the conversation survives.
|
|
99
|
+
const [panelMounted, setPanelMounted] = useState(false);
|
|
100
|
+
const setPanelOpen = useCallback((next) => {
|
|
101
|
+
if (next)
|
|
102
|
+
setPanelMounted(true);
|
|
103
|
+
setPanelOpenRaw(next);
|
|
104
|
+
}, []);
|
|
105
|
+
const [gateOpen, setGateOpenRaw] = useState(false);
|
|
106
|
+
const [gateMounted, setGateMounted] = useState(false);
|
|
107
|
+
const [request, setRequest] = useState(null);
|
|
108
|
+
const prefetch = useCallback(() => {
|
|
109
|
+
void gate().catch(() => undefined);
|
|
110
|
+
}, [gate]);
|
|
111
|
+
const idle = useIdle(prefetchWhen === false ? false : prefetchWhen === true ? 1500 : prefetchWhen);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (idle && available)
|
|
114
|
+
prefetch();
|
|
115
|
+
}, [idle, available, prefetch]);
|
|
116
|
+
// iOS opens the keyboard only for a focus inside the tap itself, and the gate may still be
|
|
117
|
+
// loading then. So the tap focuses this always-present field, and the gate moves the focus to
|
|
118
|
+
// its own input once mounted (iOS keeps the keyboard up across that move).
|
|
119
|
+
const bridge = useRef(null);
|
|
120
|
+
const returnFocus = useRef(null);
|
|
121
|
+
const askRef = useRef(ask);
|
|
122
|
+
askRef.current = ask;
|
|
123
|
+
const openGate = useCallback(() => {
|
|
124
|
+
const active = document.activeElement;
|
|
125
|
+
if (active !== bridge.current && !active?.closest("[data-spotlight-gate]"))
|
|
126
|
+
returnFocus.current = active;
|
|
127
|
+
bridge.current?.focus({ preventScroll: true });
|
|
128
|
+
setGateMounted(true);
|
|
129
|
+
setGateOpenRaw(true);
|
|
130
|
+
if (panel && askRef.current)
|
|
131
|
+
void panel().catch(() => undefined);
|
|
132
|
+
void Promise.resolve(askRef.current?.prepare?.()).catch(() => undefined);
|
|
133
|
+
}, [panel]);
|
|
134
|
+
const closeGate = useCallback(() => setGateOpenRaw(false), []);
|
|
135
|
+
const setGateOpen = useCallback((open) => (open ? openGate() : closeGate()), [openGate, closeGate]);
|
|
136
|
+
const restoreFocus = useCallback(() => {
|
|
137
|
+
const el = returnFocus.current;
|
|
138
|
+
returnFocus.current = null;
|
|
139
|
+
if (el && el !== document.body && el.isConnected)
|
|
140
|
+
el.focus({ preventScroll: true });
|
|
141
|
+
else
|
|
142
|
+
bridge.current?.blur();
|
|
143
|
+
}, []);
|
|
144
|
+
const handOff = useCallback((r) => {
|
|
145
|
+
returnFocus.current = null;
|
|
146
|
+
setGateOpenRaw(false);
|
|
147
|
+
setRequest((prev) => ({ n: (prev?.n ?? 0) + 1, r }));
|
|
148
|
+
setPanelOpen(true);
|
|
149
|
+
}, [setPanelOpen]);
|
|
150
|
+
// The shortcut opens the gate from anywhere, even from a text field or over the open panel;
|
|
151
|
+
// pressed again it closes the gate, like Spotlight.
|
|
152
|
+
const gateOpenRef = useRef(gateOpen);
|
|
153
|
+
gateOpenRef.current = gateOpen;
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!available || !shortcut)
|
|
156
|
+
return;
|
|
157
|
+
const key = shortcut.split("+").at(-1).toLowerCase();
|
|
158
|
+
const mac = isMac();
|
|
159
|
+
const onKey = (e) => {
|
|
160
|
+
if (e.key.toLowerCase() !== key || e.altKey || e.shiftKey)
|
|
161
|
+
return;
|
|
162
|
+
if (mac ? !e.metaKey || e.ctrlKey : !e.ctrlKey || e.metaKey)
|
|
163
|
+
return;
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
if (gateOpenRef.current)
|
|
166
|
+
setGateOpenRaw(false);
|
|
167
|
+
else
|
|
168
|
+
openGate();
|
|
169
|
+
};
|
|
170
|
+
window.addEventListener("keydown", onKey);
|
|
171
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
172
|
+
}, [available, shortcut, openGate]);
|
|
173
|
+
const value = useMemo(() => ({
|
|
174
|
+
mode,
|
|
175
|
+
available,
|
|
176
|
+
search,
|
|
177
|
+
ask,
|
|
178
|
+
actions,
|
|
179
|
+
copy: merged,
|
|
180
|
+
navigate,
|
|
181
|
+
persist,
|
|
182
|
+
gateOpen,
|
|
183
|
+
openGate,
|
|
184
|
+
closeGate,
|
|
185
|
+
setGateOpen,
|
|
186
|
+
panelOpen,
|
|
187
|
+
setPanelOpen,
|
|
188
|
+
prefetch,
|
|
189
|
+
handOff,
|
|
190
|
+
request,
|
|
191
|
+
restoreFocus,
|
|
192
|
+
}), [mode, available, search, ask, actions, merged, navigate, persist, gateOpen, openGate, closeGate, setGateOpen, panelOpen, setPanelOpen, prefetch, handOff, request, restoreFocus]);
|
|
193
|
+
return (_jsxs(SpotlightContext.Provider, { value: value, children: [children, available && (_jsx("input", { ref: bridge, "aria-hidden": true, tabIndex: -1, "data-spotlight-bridge": "", style: { position: "fixed", top: 0, left: 0, width: 1, height: 1, fontSize: 16, opacity: 0, pointerEvents: "none", border: 0, padding: 0 } })), available && gateMounted && Gate && (_jsx(Suspense, { fallback: null, children: _jsx(Gate, { open: gateOpen, onOpenChange: setGateOpen }) })), available && ask && panelMounted && Panel && (_jsx(Suspense, { fallback: null, children: _jsx(Panel, { open: panelOpen, onOpenChange: setPanelOpen }) }))] }));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* A headless trigger: a `<button>` that opens the gate and prefetches it on pointer enter, focus
|
|
197
|
+
* and touch. Renders nothing while Spotlight is unavailable, unless `always` (for a trigger that
|
|
198
|
+
* must be in the server HTML, so the layout never shifts).
|
|
199
|
+
*/
|
|
200
|
+
export function Trigger({ children, always, onClick, ...props }) {
|
|
201
|
+
const s = useSpotlight();
|
|
202
|
+
if (!s.available && !always)
|
|
203
|
+
return null;
|
|
204
|
+
return (_jsx("button", { type: "button", "aria-haspopup": "dialog", "data-spotlight-trigger": "", ...props, onClick: (e) => {
|
|
205
|
+
onClick?.(e);
|
|
206
|
+
if (!e.defaultPrevented)
|
|
207
|
+
s.openGate();
|
|
208
|
+
}, onPointerEnter: (e) => {
|
|
209
|
+
props.onPointerEnter?.(e);
|
|
210
|
+
s.prefetch();
|
|
211
|
+
}, onFocus: (e) => {
|
|
212
|
+
props.onFocus?.(e);
|
|
213
|
+
s.prefetch();
|
|
214
|
+
}, onTouchStart: (e) => {
|
|
215
|
+
props.onTouchStart?.(e);
|
|
216
|
+
s.prefetch();
|
|
217
|
+
}, children: children }));
|
|
218
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface TurnstileWidgetOptions {
|
|
2
|
+
siteKey: string;
|
|
3
|
+
/** The widget's language (`auto` by default). */
|
|
4
|
+
language?: string;
|
|
5
|
+
/** Class on the container the widget renders into (appended to `<body>`): place its checkbox. */
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TurnstileWidget {
|
|
9
|
+
/** Load the script and start on a token. Safe to call often. */
|
|
10
|
+
prepare: () => void;
|
|
11
|
+
/** A fresh token (waits for one if none is ready); `undefined` if Turnstile failed. */
|
|
12
|
+
token: () => Promise<string | undefined>;
|
|
13
|
+
/** Remove the widget and its container. */
|
|
14
|
+
dispose: () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function turnstileWidget({ siteKey, language, className }: TurnstileWidgetOptions): TurnstileWidget;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Cloudflare Turnstile in the browser, for `ask.prepare` and `ask.body`: the script is injected
|
|
2
|
+
// only when `prepare()` first runs (the gate opening), the widget runs invisibly
|
|
3
|
+
// ("interaction-only": a checkbox shows only when Cloudflare is unsure). A token is single use;
|
|
4
|
+
// `token()` hands the ready one over and the widget starts on the next.
|
|
5
|
+
//
|
|
6
|
+
// const bot = turnstileWidget({siteKey})
|
|
7
|
+
// ask={{endpoint: "/api/ask", prepare: bot.prepare, body: async () => ({turnstileToken: await bot.token()})}}
|
|
8
|
+
//
|
|
9
|
+
// Pairs with `turnstile({secret})` from `@perxel/spotlight/server`.
|
|
10
|
+
const SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
11
|
+
let loading = null;
|
|
12
|
+
function load() {
|
|
13
|
+
const w = window;
|
|
14
|
+
if (w.turnstile)
|
|
15
|
+
return Promise.resolve(w.turnstile);
|
|
16
|
+
loading ??= new Promise((resolve, reject) => {
|
|
17
|
+
const script = document.createElement("script");
|
|
18
|
+
script.src = SRC;
|
|
19
|
+
script.async = true;
|
|
20
|
+
script.onload = () => (w.turnstile ? resolve(w.turnstile) : reject(new Error("turnstile")));
|
|
21
|
+
script.onerror = () => {
|
|
22
|
+
loading = null;
|
|
23
|
+
reject(new Error("turnstile"));
|
|
24
|
+
};
|
|
25
|
+
document.head.appendChild(script);
|
|
26
|
+
});
|
|
27
|
+
return loading;
|
|
28
|
+
}
|
|
29
|
+
export function turnstileWidget({ siteKey, language = "auto", className }) {
|
|
30
|
+
let el = null;
|
|
31
|
+
let widgetId = null;
|
|
32
|
+
let ready = null;
|
|
33
|
+
let token = null;
|
|
34
|
+
let waiting = [];
|
|
35
|
+
const start = () => {
|
|
36
|
+
if (ready)
|
|
37
|
+
return ready;
|
|
38
|
+
el = document.createElement("div");
|
|
39
|
+
el.dataset.spotlightTurnstile = "";
|
|
40
|
+
if (className)
|
|
41
|
+
el.className = className;
|
|
42
|
+
else
|
|
43
|
+
Object.assign(el.style, { position: "fixed", left: "50%", bottom: "16px", transform: "translateX(-50%)", zIndex: "2147483000" });
|
|
44
|
+
document.body.appendChild(el);
|
|
45
|
+
ready = load().then((api) => {
|
|
46
|
+
widgetId = api.render(el, {
|
|
47
|
+
sitekey: siteKey,
|
|
48
|
+
appearance: "interaction-only",
|
|
49
|
+
language,
|
|
50
|
+
callback: (t) => {
|
|
51
|
+
const next = waiting.shift();
|
|
52
|
+
if (next) {
|
|
53
|
+
next(t);
|
|
54
|
+
api.reset(widgetId);
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
token = t;
|
|
58
|
+
},
|
|
59
|
+
"error-callback": () => {
|
|
60
|
+
for (const w of waiting)
|
|
61
|
+
w(undefined);
|
|
62
|
+
waiting = [];
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return api;
|
|
66
|
+
});
|
|
67
|
+
ready.catch(() => {
|
|
68
|
+
for (const w of waiting)
|
|
69
|
+
w(undefined);
|
|
70
|
+
waiting = [];
|
|
71
|
+
});
|
|
72
|
+
return ready;
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
prepare: () => void start().catch(() => undefined),
|
|
76
|
+
async token() {
|
|
77
|
+
const api = await start().catch(() => null);
|
|
78
|
+
if (!api)
|
|
79
|
+
return undefined;
|
|
80
|
+
if (token) {
|
|
81
|
+
const t = token;
|
|
82
|
+
token = null;
|
|
83
|
+
if (widgetId)
|
|
84
|
+
api.reset(widgetId); // the next call gets a fresh one
|
|
85
|
+
return t;
|
|
86
|
+
}
|
|
87
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
88
|
+
},
|
|
89
|
+
dispose() {
|
|
90
|
+
const w = window;
|
|
91
|
+
if (widgetId)
|
|
92
|
+
w.turnstile?.remove(widgetId);
|
|
93
|
+
el?.remove();
|
|
94
|
+
el = null;
|
|
95
|
+
ready = null;
|
|
96
|
+
widgetId = null;
|
|
97
|
+
token = null;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|