imprensa 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/default.css +3 -1
- package/dist/index.mjs +12 -0
- package/package.json +2 -1
- package/src/components/classes.ts +3 -0
- package/src/components/doc-pager.tsx +97 -0
- package/src/components/doc-toolbar.tsx +143 -0
- package/src/components/doc.tsx +2 -0
- package/src/components/global-search.tsx +141 -0
- package/src/components/icons.tsx +45 -0
- package/src/components/ilha-ui.ts +6 -0
- package/src/components/index.tsx +13 -0
- package/src/components/layout.tsx +110 -0
- package/src/components/mobile-nav.tsx +102 -0
- package/src/components/nav-footer-bar.tsx +34 -0
- package/src/components/preview.tsx +83 -0
- package/src/components/search-core.tsx +47 -0
- package/src/components/search-portal-sync.ts +234 -0
- package/src/components/search-store.ts +34 -0
- package/src/components/search.tsx +140 -0
- package/src/components/sidebar-layout.ts +53 -0
- package/src/components/sidebar.tsx +76 -0
- package/src/components/snippet.tsx +38 -0
- package/src/components/tree-indent.ts +14 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** @jsxImportSource ilha */
|
|
2
|
+
import { Button, Icon, LinkButton, Popover } from "areia";
|
|
3
|
+
import { ExternalLink } from "lucide";
|
|
4
|
+
import { contentTree, type ContentTreeNode } from "imprensa/mdx";
|
|
5
|
+
import { NavFooterBar } from "./nav-footer-bar";
|
|
6
|
+
import { LogoButton } from "./search";
|
|
7
|
+
import type { ImprensaUiTree } from "./ilha-ui";
|
|
8
|
+
import { cx } from "./classes";
|
|
9
|
+
import { treeIndentClass } from "./tree-indent";
|
|
10
|
+
|
|
11
|
+
const ACTIVE_LINK_CLASS = "border-areia-primary text-areia-primary ring-1 ring-areia-primary/30";
|
|
12
|
+
|
|
13
|
+
function normalizePath(path: string) {
|
|
14
|
+
return path.replace(/\/$/, "") || "/";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderMobileTree(
|
|
18
|
+
nodes: ContentTreeNode[],
|
|
19
|
+
currentPath: string,
|
|
20
|
+
depth = 0,
|
|
21
|
+
): ImprensaUiTree[] {
|
|
22
|
+
return nodes.flatMap((node) => {
|
|
23
|
+
const href = node.type === "link" ? node.link : node.path;
|
|
24
|
+
const active =
|
|
25
|
+
node.path && node.type !== "link" ? normalizePath(node.path) === currentPath : false;
|
|
26
|
+
const items: ImprensaUiTree[] = [];
|
|
27
|
+
if (href) {
|
|
28
|
+
items.push(
|
|
29
|
+
<Popover.Close>
|
|
30
|
+
<div class={treeIndentClass(depth)}>
|
|
31
|
+
<LinkButton
|
|
32
|
+
href={href}
|
|
33
|
+
external={node.external}
|
|
34
|
+
variant={active ? "outline" : "ghost"}
|
|
35
|
+
class={cx(
|
|
36
|
+
"w-full justify-start",
|
|
37
|
+
node.type === "link" && node.external && "justify-between",
|
|
38
|
+
active && ACTIVE_LINK_CLASS,
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
<span class="min-w-0 truncate">{node.title}</span>
|
|
42
|
+
{node.type === "link" && node.external ? (
|
|
43
|
+
<Icon icon={ExternalLink} class="size-3.5 shrink-0" />
|
|
44
|
+
) : null}
|
|
45
|
+
</LinkButton>
|
|
46
|
+
</div>
|
|
47
|
+
</Popover.Close>,
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
items.push(
|
|
51
|
+
<span
|
|
52
|
+
class={cx("mt-2 block text-sm font-medium text-areia-subtle", treeIndentClass(depth))}
|
|
53
|
+
>
|
|
54
|
+
{node.title}
|
|
55
|
+
</span>,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (node.children.length > 0)
|
|
59
|
+
items.push(...renderMobileTree(node.children, currentPath, depth + 1));
|
|
60
|
+
return items;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function MobileNavigationPopover(props: { currentPath: string }) {
|
|
65
|
+
const currentPath = normalizePath(props.currentPath);
|
|
66
|
+
return (
|
|
67
|
+
<Popover
|
|
68
|
+
side="bottom"
|
|
69
|
+
align="start"
|
|
70
|
+
contentClass="z-50"
|
|
71
|
+
trigger={
|
|
72
|
+
<Button shape="square" aria-label="Open navigation">
|
|
73
|
+
<svg
|
|
74
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
75
|
+
class="size-5"
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
stroke-width="2"
|
|
80
|
+
stroke-linecap="round"
|
|
81
|
+
stroke-linejoin="round"
|
|
82
|
+
>
|
|
83
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
84
|
+
<line x1="3" y1="12" x2="21" y2="12" />
|
|
85
|
+
<line x1="3" y1="18" x2="21" y2="18" />
|
|
86
|
+
</svg>
|
|
87
|
+
</Button>
|
|
88
|
+
}
|
|
89
|
+
content={
|
|
90
|
+
<div class="flex w-64 max-h-[70dvh] flex-col gap-2 p-1">
|
|
91
|
+
<div class="flex shrink-0 items-center px-1 pb-1">
|
|
92
|
+
<LogoButton />
|
|
93
|
+
</div>
|
|
94
|
+
<nav class="min-h-0 flex-1 overflow-y-auto flex flex-col gap-1">
|
|
95
|
+
{renderMobileTree(contentTree, currentPath)}
|
|
96
|
+
</nav>
|
|
97
|
+
<NavFooterBar />
|
|
98
|
+
</div>
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** @jsxImportSource ilha */
|
|
2
|
+
import { LinkButton } from "areia";
|
|
3
|
+
import { socials } from "imprensa/config";
|
|
4
|
+
import { cx } from "./classes";
|
|
5
|
+
import { Icon } from "./icons";
|
|
6
|
+
import { ThemeToggle } from "./search";
|
|
7
|
+
|
|
8
|
+
export function NavFooterBar(props: { class?: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
class={cx(
|
|
12
|
+
"flex shrink-0 items-center justify-between rounded-lg border border-areia-border bg-areia-surface-muted/60 p-1",
|
|
13
|
+
props.class,
|
|
14
|
+
)}
|
|
15
|
+
>
|
|
16
|
+
<div class="flex items-center gap-0">
|
|
17
|
+
{socials.map((s) => (
|
|
18
|
+
<LinkButton
|
|
19
|
+
href={s.url}
|
|
20
|
+
shape="square"
|
|
21
|
+
variant="ghost"
|
|
22
|
+
icon={<Icon icon={s.service} class="size-4" />}
|
|
23
|
+
external
|
|
24
|
+
aria-label={s.service}
|
|
25
|
+
class="shrink-0"
|
|
26
|
+
/>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
<div class="ml-auto">
|
|
30
|
+
<ThemeToggle />
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** @jsxImportSource ilha */
|
|
2
|
+
import ilha from "ilha";
|
|
3
|
+
import { preview as previewConfig, shikiThemes } from "imprensa/config";
|
|
4
|
+
import type { ImprensaShikiHighlighter } from "../core/shiki-types";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_IMPORTMAP = { imports: {} as Record<string, string> };
|
|
7
|
+
|
|
8
|
+
const DEFAULT_HEAD = ``;
|
|
9
|
+
|
|
10
|
+
function makeIframeDoc(code: string) {
|
|
11
|
+
const script = code
|
|
12
|
+
.replace(/^export default /m, "const __island = ")
|
|
13
|
+
.replace(/^export const (\w+)/m, "const $1");
|
|
14
|
+
|
|
15
|
+
const importmap = previewConfig.importmap
|
|
16
|
+
? { imports: { ...DEFAULT_IMPORTMAP.imports, ...JSON.parse(previewConfig.importmap).imports } }
|
|
17
|
+
: DEFAULT_IMPORTMAP;
|
|
18
|
+
|
|
19
|
+
const head = previewConfig.head ?? DEFAULT_HEAD;
|
|
20
|
+
|
|
21
|
+
return `<!DOCTYPE html>
|
|
22
|
+
<html>
|
|
23
|
+
<head>
|
|
24
|
+
<meta charset="utf-8">
|
|
25
|
+
<script>const localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };</script>
|
|
26
|
+
<script type="importmap">${JSON.stringify(importmap)}</script>
|
|
27
|
+
<script type="module" src="https://esm.sh/tsx"></script>
|
|
28
|
+
${head}
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<div id="root"></div>
|
|
32
|
+
<script type="text/babel" data-type="module">
|
|
33
|
+
${script}
|
|
34
|
+
const __resolved = typeof __island !== 'undefined' ? __island : typeof App !== 'undefined' ? App : undefined;
|
|
35
|
+
if (__resolved?.mount) __resolved.mount(document.getElementById('root'));
|
|
36
|
+
</script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Preview = ilha
|
|
42
|
+
.input<{ code64: string }>()
|
|
43
|
+
.onMount(({ host, input }) => {
|
|
44
|
+
const code = atob(input.code64);
|
|
45
|
+
const wrapper = host.querySelector<HTMLElement>(".preview-wrapper")!;
|
|
46
|
+
|
|
47
|
+
const pre = document.createElement("pre");
|
|
48
|
+
pre.className =
|
|
49
|
+
"rounded-lg bg-areia-surface-muted border border-areia-border p-4 overflow-x-auto text-sm";
|
|
50
|
+
pre.innerHTML = `<code>${code.replace(/&/g, "&").replace(/</g, "<")}</code>`;
|
|
51
|
+
wrapper.appendChild(pre);
|
|
52
|
+
|
|
53
|
+
const iframe = document.createElement("iframe");
|
|
54
|
+
iframe.className = "rounded-lg border border-areia-border w-full min-h-32";
|
|
55
|
+
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
|
56
|
+
iframe.srcdoc = makeIframeDoc(code);
|
|
57
|
+
wrapper.appendChild(iframe);
|
|
58
|
+
|
|
59
|
+
iframe.addEventListener("load", () => {
|
|
60
|
+
const syncDark = () => {
|
|
61
|
+
const dark = document.documentElement.classList.contains("dark");
|
|
62
|
+
iframe.contentDocument?.documentElement.classList.toggle("dark", dark);
|
|
63
|
+
};
|
|
64
|
+
syncDark();
|
|
65
|
+
new MutationObserver(syncDark).observe(document.documentElement, {
|
|
66
|
+
attributeFilter: ["class"],
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
void import("imprensa/shiki").then(async ({ shiki }) => {
|
|
71
|
+
const previewHighlighter = shiki as ImprensaShikiHighlighter;
|
|
72
|
+
await previewHighlighter.loadLanguage("tsx");
|
|
73
|
+
const div = document.createElement("div");
|
|
74
|
+
div.className =
|
|
75
|
+
"rounded-lg overflow-hidden border border-areia-border text-sm [&_pre]:!p-4 [&_pre]:!m-0 [&_pre]:overflow-x-auto";
|
|
76
|
+
div.innerHTML = previewHighlighter.codeToHtml(code, {
|
|
77
|
+
lang: "tsx",
|
|
78
|
+
themes: shikiThemes,
|
|
79
|
+
});
|
|
80
|
+
wrapper.replaceChild(div, pre);
|
|
81
|
+
});
|
|
82
|
+
})
|
|
83
|
+
.render(() => <div class="not-prose flex flex-col gap-4 preview-wrapper" />);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import MiniSearch from "minisearch";
|
|
2
|
+
import { searchDocuments } from "imprensa/mdx";
|
|
3
|
+
|
|
4
|
+
type SearchDocument = (typeof searchDocuments)[number];
|
|
5
|
+
export type SearchResult = Pick<SearchDocument, "id" | "title" | "path" | "text">;
|
|
6
|
+
|
|
7
|
+
const searchIndex = new MiniSearch<SearchDocument>({
|
|
8
|
+
fields: ["title", "text"],
|
|
9
|
+
storeFields: ["title", "path", "text"],
|
|
10
|
+
searchOptions: { boost: { title: 3 }, fuzzy: 0.2, prefix: true },
|
|
11
|
+
});
|
|
12
|
+
searchIndex.addAll(searchDocuments);
|
|
13
|
+
|
|
14
|
+
function getQueryTerms(query: string) {
|
|
15
|
+
return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getTextMatchIndex(text: string, query: string) {
|
|
19
|
+
const normalizedText = text.toLowerCase();
|
|
20
|
+
return getQueryTerms(query)
|
|
21
|
+
.map((term) => normalizedText.indexOf(term))
|
|
22
|
+
.filter((index) => index >= 0)
|
|
23
|
+
.sort((a, b) => a - b)[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getMatchedExcerpt(text: string, query: string) {
|
|
27
|
+
const firstMatch = getTextMatchIndex(text, query);
|
|
28
|
+
if (firstMatch === undefined) return undefined;
|
|
29
|
+
const start = Math.max(0, firstMatch - 60);
|
|
30
|
+
const end = Math.min(text.length, firstMatch + 140);
|
|
31
|
+
const excerpt = text.slice(start, end).trim();
|
|
32
|
+
return `${start > 0 ? "…" : ""}${excerpt}${end < text.length ? "…" : ""}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getSearchResults(query: string): SearchResult[] {
|
|
36
|
+
const trimmedQuery = query.trim();
|
|
37
|
+
if (!trimmedQuery) return [];
|
|
38
|
+
return searchIndex
|
|
39
|
+
.search(trimmedQuery)
|
|
40
|
+
.slice(0, 8)
|
|
41
|
+
.map((result) => ({
|
|
42
|
+
id: result.id,
|
|
43
|
+
title: result.title,
|
|
44
|
+
path: result.path,
|
|
45
|
+
text: result.text,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Areia Dialog portals to document.body; Ilha does not morph that subtree.
|
|
3
|
+
* Syncs command-list + keyboard nav until Areia supports portal={false} on body hosts.
|
|
4
|
+
*/
|
|
5
|
+
import { Command as CommandPrimitive } from "@areia/slots";
|
|
6
|
+
import { getMatchedExcerpt, getSearchResults, type SearchResult } from "./search-core";
|
|
7
|
+
|
|
8
|
+
function escapeHtml(text: string) {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function highlightPlain(text: string, query: string) {
|
|
17
|
+
const terms = query
|
|
18
|
+
.trim()
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.split(/\s+/)
|
|
21
|
+
.filter((t) => t.length > 1);
|
|
22
|
+
if (terms.length === 0) return escapeHtml(text);
|
|
23
|
+
const pattern = new RegExp(
|
|
24
|
+
`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`,
|
|
25
|
+
"gi",
|
|
26
|
+
);
|
|
27
|
+
return text
|
|
28
|
+
.split(pattern)
|
|
29
|
+
.map((part) =>
|
|
30
|
+
terms.includes(part.toLowerCase())
|
|
31
|
+
? `<mark class="rounded bg-areia-primary px-0.5 text-areia-primary-foreground">${escapeHtml(part)}</mark>`
|
|
32
|
+
: escapeHtml(part),
|
|
33
|
+
)
|
|
34
|
+
.join("");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function findOpenSearchCommandRoot(): HTMLElement | null {
|
|
38
|
+
const portal = document.querySelector('[data-slot="dialog-portal"][data-state="open"]');
|
|
39
|
+
if (portal) {
|
|
40
|
+
const inPortal = portal.querySelector<HTMLElement>(
|
|
41
|
+
'[data-imprensa-search-dialog][data-slot="command"]',
|
|
42
|
+
);
|
|
43
|
+
if (inPortal) return inPortal;
|
|
44
|
+
}
|
|
45
|
+
return document.querySelector<HTMLElement>('[data-imprensa-search-dialog][data-slot="command"]');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findListIn(root: HTMLElement | null): HTMLElement | null {
|
|
49
|
+
if (!root) return null;
|
|
50
|
+
return root.querySelector('[data-slot="command-list"]');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findInputIn(root: HTMLElement | null): HTMLInputElement | null {
|
|
54
|
+
if (!root) return null;
|
|
55
|
+
return root.querySelector("[data-search-input]");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function syncPortaledSearchList(
|
|
59
|
+
query: string,
|
|
60
|
+
results: SearchResult[],
|
|
61
|
+
root?: HTMLElement | null,
|
|
62
|
+
) {
|
|
63
|
+
const commandRoot = root ?? findOpenSearchCommandRoot();
|
|
64
|
+
const list = findListIn(commandRoot);
|
|
65
|
+
if (!list) return false;
|
|
66
|
+
|
|
67
|
+
const trimmed = query.trim();
|
|
68
|
+
if (!trimmed) {
|
|
69
|
+
list.classList.add("hidden");
|
|
70
|
+
list.innerHTML = "";
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
list.classList.remove("hidden");
|
|
75
|
+
|
|
76
|
+
if (results.length === 0) {
|
|
77
|
+
list.innerHTML = `<div data-slot="command-group">
|
|
78
|
+
<div data-slot="command-group-heading" class="px-3 py-1 text-xs font-medium text-areia-foreground/60">Pages</div>
|
|
79
|
+
<div data-slot="command-empty" class="px-3 py-6 text-center text-sm text-areia-foreground/60">No results found.</div>
|
|
80
|
+
</div>`;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const items = results
|
|
85
|
+
.map((result) => {
|
|
86
|
+
const excerpt = getMatchedExcerpt(result.text, query);
|
|
87
|
+
const titleHtml = highlightPlain(result.title, query);
|
|
88
|
+
const excerptHtml = excerpt
|
|
89
|
+
? `<div class="mt-1 line-clamp-2 text-xs text-areia-foreground/60">${highlightPlain(excerpt, query)}</div>`
|
|
90
|
+
: "";
|
|
91
|
+
return `<div data-slot="command-item" data-value="${escapeHtml(result.path)}" data-keywords="${escapeHtml(`${result.title}\n${result.text}`)}" class="cursor-pointer rounded-lg px-3 py-2 text-sm data-selected:bg-areia-control-hover">
|
|
92
|
+
<div class="font-medium">${titleHtml}</div>
|
|
93
|
+
${excerptHtml}
|
|
94
|
+
</div>`;
|
|
95
|
+
})
|
|
96
|
+
.join("");
|
|
97
|
+
|
|
98
|
+
list.innerHTML = `<div data-slot="command-group">
|
|
99
|
+
<div data-slot="command-group-heading" class="px-3 py-1 text-xs font-medium text-areia-foreground/60">Pages</div>
|
|
100
|
+
${items}
|
|
101
|
+
</div>`;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type PortaledSearchBridgeOptions = {
|
|
106
|
+
isOpen: () => boolean;
|
|
107
|
+
getQuery: () => string;
|
|
108
|
+
setQuery: (value: string) => void;
|
|
109
|
+
onClose: () => void;
|
|
110
|
+
onNavigate: (path: string) => void;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function syncInputFromSaved(root: HTMLElement, saved: string) {
|
|
114
|
+
const input = findInputIn(root);
|
|
115
|
+
if (input && input.value !== saved) input.value = saved;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function searchDialogOpenInDom(): boolean {
|
|
119
|
+
return findOpenSearchCommandRoot() !== null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function attachPortaledSearchBridge(options: PortaledSearchBridgeOptions) {
|
|
123
|
+
const { isOpen, getQuery, setQuery, onClose, onNavigate } = options;
|
|
124
|
+
let commandDestroy = () => {};
|
|
125
|
+
let commandRoot: HTMLElement | null = null;
|
|
126
|
+
|
|
127
|
+
const repaint = (query: string, root?: HTMLElement | null) => {
|
|
128
|
+
syncPortaledSearchList(query, getSearchResults(query), root ?? findOpenSearchCommandRoot());
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const ensureCommand = (root: HTMLElement) => {
|
|
132
|
+
if (root === commandRoot) return;
|
|
133
|
+
commandDestroy();
|
|
134
|
+
commandRoot = root;
|
|
135
|
+
const saved = getQuery();
|
|
136
|
+
syncInputFromSaved(root, saved);
|
|
137
|
+
const controller = CommandPrimitive.createCommand(root, {
|
|
138
|
+
label: "Search documentation",
|
|
139
|
+
loop: true,
|
|
140
|
+
shouldFilter: false,
|
|
141
|
+
onSearchChange: (value: string) => {
|
|
142
|
+
setQuery(value);
|
|
143
|
+
repaint(value, root);
|
|
144
|
+
},
|
|
145
|
+
onSelect: (value: string) => {
|
|
146
|
+
onClose();
|
|
147
|
+
onNavigate(value);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
commandDestroy = () => controller.destroy();
|
|
151
|
+
repaint(saved, root);
|
|
152
|
+
findInputIn(root)?.focus();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const active = () => isOpen() || searchDialogOpenInDom();
|
|
156
|
+
|
|
157
|
+
const handleQueryFromInput = (input: HTMLInputElement) => {
|
|
158
|
+
if (!input.closest("[data-imprensa-search-dialog]")) return;
|
|
159
|
+
const root = input.closest<HTMLElement>('[data-slot="command"]');
|
|
160
|
+
if (!root) return;
|
|
161
|
+
const query = input.value;
|
|
162
|
+
setQuery(query);
|
|
163
|
+
repaint(query, root);
|
|
164
|
+
ensureCommand(root);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const onInput = (event: Event) => {
|
|
168
|
+
if (!active()) return;
|
|
169
|
+
const target = event.target;
|
|
170
|
+
if (!(target instanceof HTMLInputElement) || !target.matches("[data-search-input]")) return;
|
|
171
|
+
handleQueryFromInput(target);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const onClick = (event: MouseEvent) => {
|
|
175
|
+
if (!active()) return;
|
|
176
|
+
const item = (event.target as Element | null)?.closest<HTMLElement>(
|
|
177
|
+
'[data-slot="command-item"]',
|
|
178
|
+
);
|
|
179
|
+
if (!item?.closest("[data-imprensa-search-dialog]")) return;
|
|
180
|
+
const path = item.getAttribute("data-value");
|
|
181
|
+
if (!path) return;
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
onClose();
|
|
184
|
+
onNavigate(path);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
document.addEventListener("input", onInput, true);
|
|
188
|
+
document.addEventListener("click", onClick, true);
|
|
189
|
+
|
|
190
|
+
const tick = () => {
|
|
191
|
+
if (!active()) {
|
|
192
|
+
if (commandRoot) {
|
|
193
|
+
commandDestroy();
|
|
194
|
+
commandDestroy = () => {};
|
|
195
|
+
commandRoot = null;
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const root = findOpenSearchCommandRoot();
|
|
200
|
+
if (!root) return;
|
|
201
|
+
const saved = getQuery();
|
|
202
|
+
syncInputFromSaved(root, saved);
|
|
203
|
+
ensureCommand(root);
|
|
204
|
+
repaint(saved, root);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const interval = window.setInterval(tick, 50);
|
|
208
|
+
tick();
|
|
209
|
+
|
|
210
|
+
return () => {
|
|
211
|
+
window.clearInterval(interval);
|
|
212
|
+
document.removeEventListener("input", onInput, true);
|
|
213
|
+
document.removeEventListener("click", onClick, true);
|
|
214
|
+
commandDestroy();
|
|
215
|
+
commandRoot = null;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function onSearchPortalMounted(container: HTMLElement, getSavedQuery: () => string) {
|
|
220
|
+
const root = container.querySelector<HTMLElement>(
|
|
221
|
+
'[data-imprensa-search-dialog][data-slot="command"]',
|
|
222
|
+
);
|
|
223
|
+
if (!root) return;
|
|
224
|
+
const input = findInputIn(root);
|
|
225
|
+
const query = getSavedQuery();
|
|
226
|
+
if (input) input.value = query;
|
|
227
|
+
syncPortaledSearchList(query, getSearchResults(query), root);
|
|
228
|
+
input?.focus();
|
|
229
|
+
if (query.length > 0) {
|
|
230
|
+
requestAnimationFrame(() => {
|
|
231
|
+
input?.setSelectionRange(query.length, query.length);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createStore } from "@ilha/store";
|
|
2
|
+
|
|
3
|
+
export type ImprensaSearchState = {
|
|
4
|
+
open: boolean;
|
|
5
|
+
query: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const searchStore = createStore<ImprensaSearchState>({
|
|
9
|
+
open: false,
|
|
10
|
+
query: "",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** Hoisted for `bind:open` / portaled bridge — same alien-signals graph as islands. */
|
|
14
|
+
export const searchOpen = searchStore.bind((s) => s.open);
|
|
15
|
+
export const searchQuery = searchStore.bind((s) => s.query);
|
|
16
|
+
|
|
17
|
+
export function closeSearch() {
|
|
18
|
+
searchOpen(false);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function openSearch() {
|
|
22
|
+
searchOpen(true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toggleSearch() {
|
|
26
|
+
if (searchOpen()) closeSearch();
|
|
27
|
+
else openSearch();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Legacy alias for store bind accessors (optional app code). */
|
|
31
|
+
export type SearchBindAccessors = {
|
|
32
|
+
dialogOpen: typeof searchOpen;
|
|
33
|
+
query: typeof searchQuery;
|
|
34
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** @jsxImportSource ilha */
|
|
2
|
+
import { Button, Icon, LinkButton } from "areia";
|
|
3
|
+
import ilha from "ilha";
|
|
4
|
+
import { Command, Monitor, Moon, Search, Sun } from "lucide";
|
|
5
|
+
import { applyThemeToHtml, getStoredTheme, setStoredTheme } from "imprensa/runtime";
|
|
6
|
+
|
|
7
|
+
export { getSearchResults, type SearchResult } from "./search-core";
|
|
8
|
+
export {
|
|
9
|
+
closeSearch,
|
|
10
|
+
closeSearch as closeSearchDialog,
|
|
11
|
+
openSearch,
|
|
12
|
+
searchOpen,
|
|
13
|
+
searchQuery,
|
|
14
|
+
searchStore,
|
|
15
|
+
toggleSearch,
|
|
16
|
+
type ImprensaSearchState,
|
|
17
|
+
type SearchBindAccessors,
|
|
18
|
+
} from "./search-store";
|
|
19
|
+
|
|
20
|
+
export function LogoButton() {
|
|
21
|
+
return (
|
|
22
|
+
<LinkButton href="/" class="font-semibold" icon={<img src="/logo.svg" class="size-6" />}>
|
|
23
|
+
Imprensa
|
|
24
|
+
</LinkButton>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ThemeToggle = ilha
|
|
29
|
+
.state("mode", "system" as "light" | "dark" | "system")
|
|
30
|
+
.onMount(({ state }) => {
|
|
31
|
+
const mode = getStoredTheme();
|
|
32
|
+
state.mode(mode);
|
|
33
|
+
|
|
34
|
+
const applySystem = () =>
|
|
35
|
+
applyThemeToHtml(window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
36
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
37
|
+
const handler = () => {
|
|
38
|
+
if (state.mode() === "system") applySystem();
|
|
39
|
+
};
|
|
40
|
+
mq.addEventListener("change", handler);
|
|
41
|
+
|
|
42
|
+
if (mode === "system") applySystem();
|
|
43
|
+
else applyThemeToHtml(mode === "dark");
|
|
44
|
+
|
|
45
|
+
return () => mq.removeEventListener("change", handler);
|
|
46
|
+
})
|
|
47
|
+
.on("button@click", ({ state }) => {
|
|
48
|
+
const next = state.mode() === "light" ? "dark" : state.mode() === "dark" ? "system" : "light";
|
|
49
|
+
state.mode(next);
|
|
50
|
+
setStoredTheme(next);
|
|
51
|
+
if (next === "system")
|
|
52
|
+
applyThemeToHtml(window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
53
|
+
else applyThemeToHtml(next === "dark");
|
|
54
|
+
})
|
|
55
|
+
.render(({ state }) => (
|
|
56
|
+
<Button
|
|
57
|
+
shape="square"
|
|
58
|
+
aria-label={
|
|
59
|
+
state.mode() === "light"
|
|
60
|
+
? "Switch to dark theme"
|
|
61
|
+
: state.mode() === "dark"
|
|
62
|
+
? "Switch to system theme"
|
|
63
|
+
: "Switch to light theme"
|
|
64
|
+
}
|
|
65
|
+
icon={
|
|
66
|
+
<Icon icon={state.mode() === "light" ? Sun : state.mode() === "dark" ? Moon : Monitor} />
|
|
67
|
+
}
|
|
68
|
+
/>
|
|
69
|
+
));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @deprecated Replaced by `GlobalSearch` (body-mounted in `createImprensa().init()`).
|
|
73
|
+
* Kept so the vite plugin island registry stays stable.
|
|
74
|
+
*/
|
|
75
|
+
export const SearchOverlay = ilha.render(() => <></>);
|
|
76
|
+
|
|
77
|
+
export function SearchTriggerButton(props: { class?: string }) {
|
|
78
|
+
return (
|
|
79
|
+
<Button
|
|
80
|
+
type="button"
|
|
81
|
+
data-search-trigger
|
|
82
|
+
aria-label="Search documentation"
|
|
83
|
+
icon={<Icon icon={Search} />}
|
|
84
|
+
class={props.class}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function SearchSidebarTrigger() {
|
|
90
|
+
return (
|
|
91
|
+
<div class="relative inline-flex w-full min-w-0">
|
|
92
|
+
<Button
|
|
93
|
+
type="button"
|
|
94
|
+
data-search-trigger
|
|
95
|
+
aria-label="Search documentation"
|
|
96
|
+
icon={<Icon icon={Search} />}
|
|
97
|
+
class="w-10 justify-center sm:w-full sm:justify-start"
|
|
98
|
+
>
|
|
99
|
+
<span class="hidden sm:inline">Search</span>
|
|
100
|
+
<kbd class="ml-auto hidden items-center border border-areia-border py-px px-1 rounded-full md:flex">
|
|
101
|
+
<Icon icon={Command} class="size-3" />
|
|
102
|
+
<span>K</span>
|
|
103
|
+
</kbd>
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function SearchMobileTriggerButton() {
|
|
110
|
+
return (
|
|
111
|
+
<Button
|
|
112
|
+
type="button"
|
|
113
|
+
shape="square"
|
|
114
|
+
data-search-trigger
|
|
115
|
+
aria-label="Search documentation"
|
|
116
|
+
icon={<Icon icon={Search} />}
|
|
117
|
+
class="shrink-0"
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function SearchNavbarTrigger() {
|
|
123
|
+
return (
|
|
124
|
+
<div class="relative inline-flex min-w-0">
|
|
125
|
+
<Button
|
|
126
|
+
type="button"
|
|
127
|
+
data-search-trigger
|
|
128
|
+
aria-label="Search documentation"
|
|
129
|
+
icon={<Icon icon={Search} />}
|
|
130
|
+
class="w-10 shrink-0 justify-center sm:w-auto sm:min-w-[7.5rem] sm:justify-start sm:gap-2 sm:px-3"
|
|
131
|
+
>
|
|
132
|
+
<span class="hidden sm:inline">Search</span>
|
|
133
|
+
<kbd class="ml-auto hidden items-center border border-areia-border py-px px-1 rounded-full md:inline-flex">
|
|
134
|
+
<Icon icon={Command} class="size-3" />
|
|
135
|
+
<span>K</span>
|
|
136
|
+
</kbd>
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|