@terrafirmagreg-team/search 1.2.2 → 1.2.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/package.json +3 -2
- package/src/components/dialog.tsx +157 -98
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrafirmagreg-team/search",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Section-aware search",
|
|
6
6
|
"license": "LGPL-3.0-only",
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
]
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@terrafirmagreg-team/kit": "1.2.
|
|
20
|
+
"@terrafirmagreg-team/kit": "1.2.3",
|
|
21
21
|
"@terrafirmagreg-team/sites": "1.2.1",
|
|
22
22
|
"@terrafirmagreg-team/translate": "1.2.3",
|
|
23
23
|
"@terrafirmagreg-team/ui": "1.2.2",
|
|
24
24
|
"fumadocs-core": "16.17.8",
|
|
25
|
+
"lucide-react": "^0.577.0",
|
|
25
26
|
"server-only": "^0.0.1"
|
|
26
27
|
},
|
|
27
28
|
"peerDependencies": {
|
|
@@ -21,15 +21,21 @@ import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolv
|
|
|
21
21
|
import { usePathname } from 'next/navigation';
|
|
22
22
|
import { useLocationSearchParams } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
|
|
23
23
|
import { useMemo, useState } from 'react';
|
|
24
|
+
import { ChevronDown } from 'lucide-react';
|
|
25
|
+
import {
|
|
26
|
+
DropdownMenu,
|
|
27
|
+
DropdownMenuCheckboxItem,
|
|
28
|
+
DropdownMenuContent,
|
|
29
|
+
DropdownMenuItem,
|
|
30
|
+
DropdownMenuSeparator,
|
|
31
|
+
DropdownMenuTrigger,
|
|
32
|
+
} from '@terrafirmagreg-team/ui/components/ui/dropdown-menu';
|
|
24
33
|
import { Skeleton } from '@terrafirmagreg-team/ui/components/ui/skeleton';
|
|
25
34
|
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
26
35
|
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
27
36
|
import { useSectionCatalog } from '@terrafirmagreg-team/kit/hooks/use-catalog';
|
|
28
37
|
import { useSectionNames } from '@terrafirmagreg-team/kit/hooks/use-section-names';
|
|
29
|
-
import {
|
|
30
|
-
dumpSectionKeys,
|
|
31
|
-
parseSectionLocation,
|
|
32
|
-
} from '@terrafirmagreg-team/sites/location';
|
|
38
|
+
import { parseSectionLocation } from '@terrafirmagreg-team/sites/location';
|
|
33
39
|
import { searchHitFilter, mergeClients } from '../lib';
|
|
34
40
|
import { getSearchClientSources } from '../lib/sources';
|
|
35
41
|
|
|
@@ -37,7 +43,76 @@ const chip =
|
|
|
37
43
|
'rounded-md border px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer';
|
|
38
44
|
const chipOn = 'bg-accent text-accent-foreground border-transparent';
|
|
39
45
|
const chipOff = 'text-muted-foreground hover:text-foreground';
|
|
40
|
-
|
|
46
|
+
|
|
47
|
+
type ScopeSel = { secs: Set<string>; dss: Set<string> };
|
|
48
|
+
const EMPTY_SEL: ScopeSel = { secs: new Set(), dss: new Set() };
|
|
49
|
+
|
|
50
|
+
function toggled(set: Set<string>, id: string): Set<string> {
|
|
51
|
+
const next = new Set(set);
|
|
52
|
+
if (next.has(id)) next.delete(id);
|
|
53
|
+
else next.add(id);
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ScopeDropdown({
|
|
58
|
+
label,
|
|
59
|
+
allLabel,
|
|
60
|
+
items,
|
|
61
|
+
picked,
|
|
62
|
+
onToggle,
|
|
63
|
+
onAll,
|
|
64
|
+
}: {
|
|
65
|
+
label: string;
|
|
66
|
+
allLabel: string;
|
|
67
|
+
items: { id: string; title: string }[];
|
|
68
|
+
picked: Set<string>;
|
|
69
|
+
onToggle: (id: string) => void;
|
|
70
|
+
onAll: () => void;
|
|
71
|
+
}) {
|
|
72
|
+
const active = picked.size > 0;
|
|
73
|
+
const text =
|
|
74
|
+
picked.size === 0
|
|
75
|
+
? label
|
|
76
|
+
: picked.size === 1
|
|
77
|
+
? (items.find((item) => picked.has(item.id))?.title ??
|
|
78
|
+
[...picked][0])
|
|
79
|
+
: `${label}: ${picked.size}`;
|
|
80
|
+
return (
|
|
81
|
+
<DropdownMenu modal={false}>
|
|
82
|
+
<DropdownMenuTrigger
|
|
83
|
+
aria-pressed={active}
|
|
84
|
+
className={cn(
|
|
85
|
+
chip,
|
|
86
|
+
'inline-flex items-center gap-1',
|
|
87
|
+
active ? chipOn : chipOff,
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
{text}
|
|
91
|
+
<ChevronDown className="size-3" />
|
|
92
|
+
</DropdownMenuTrigger>
|
|
93
|
+
<DropdownMenuContent side="top" align="start" className="max-h-64 min-w-40">
|
|
94
|
+
<DropdownMenuItem
|
|
95
|
+
onClick={onAll}
|
|
96
|
+
className="gap-2 px-2.5 py-1.5 text-xs font-medium"
|
|
97
|
+
>
|
|
98
|
+
{allLabel}
|
|
99
|
+
</DropdownMenuItem>
|
|
100
|
+
<DropdownMenuSeparator />
|
|
101
|
+
{items.map(({ id, title }) => (
|
|
102
|
+
<DropdownMenuCheckboxItem
|
|
103
|
+
key={id}
|
|
104
|
+
checked={!active || picked.has(id)}
|
|
105
|
+
onCheckedChange={() => onToggle(id)}
|
|
106
|
+
closeOnClick={false}
|
|
107
|
+
className="text-xs"
|
|
108
|
+
>
|
|
109
|
+
{title}
|
|
110
|
+
</DropdownMenuCheckboxItem>
|
|
111
|
+
))}
|
|
112
|
+
</DropdownMenuContent>
|
|
113
|
+
</DropdownMenu>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
41
116
|
|
|
42
117
|
export function SearchDialog(props: SharedProps & { api: string }) {
|
|
43
118
|
const locale = useResolvedLang();
|
|
@@ -58,58 +133,69 @@ export function SearchDialog(props: SharedProps & { api: string }) {
|
|
|
58
133
|
),
|
|
59
134
|
[catalog],
|
|
60
135
|
);
|
|
61
|
-
const defaultSel = useMemo(():
|
|
62
|
-
if (section && dataset)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}, [section, dataset, catalog]);
|
|
69
|
-
const [picked, setPicked] = useState<{ key: string; sel: Set<string> }>();
|
|
136
|
+
const defaultSel = useMemo((): ScopeSel => {
|
|
137
|
+
if (section && dataset)
|
|
138
|
+
return { secs: new Set([section]), dss: new Set([dataset]) };
|
|
139
|
+
if (section) return { secs: new Set([section]), dss: new Set() };
|
|
140
|
+
return { secs: new Set(), dss: new Set() };
|
|
141
|
+
}, [section, dataset]);
|
|
142
|
+
const [picked, setPicked] = useState<{ key: string; sel: ScopeSel }>();
|
|
70
143
|
const sel = picked?.key === scopeKey ? picked.sel : defaultSel;
|
|
71
|
-
const setSel = (value:
|
|
144
|
+
const setSel = (value: ScopeSel) =>
|
|
72
145
|
setPicked({ key: scopeKey, sel: value });
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
for (const
|
|
77
|
-
if (
|
|
78
|
-
|
|
146
|
+
const everywhere = sel.secs.size === 0 && sel.dss.size === 0;
|
|
147
|
+
const scopePairs = useMemo(() => {
|
|
148
|
+
const out = new Set<string>();
|
|
149
|
+
for (const [sec, datasets] of sections) {
|
|
150
|
+
if (sel.secs.size && !sel.secs.has(sec)) continue;
|
|
151
|
+
for (const { id } of datasets) {
|
|
152
|
+
if (sel.dss.size && !sel.dss.has(id)) continue;
|
|
153
|
+
out.add(`${sec}:${id}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}, [sections, sel]);
|
|
158
|
+
const datasetOptions = useMemo(() => {
|
|
159
|
+
const map = new Map<string, string | undefined>();
|
|
160
|
+
for (const [sec, datasets] of sections) {
|
|
161
|
+
if (sel.secs.size && !sel.secs.has(sec)) continue;
|
|
162
|
+
for (const { id, title } of datasets) {
|
|
163
|
+
if (!map.has(id)) map.set(id, title);
|
|
164
|
+
}
|
|
79
165
|
}
|
|
80
|
-
|
|
81
|
-
};
|
|
166
|
+
return [...map.entries()].map(([id, title]) => ({ id, title }));
|
|
167
|
+
}, [sections, sel.secs]);
|
|
82
168
|
|
|
83
169
|
const client = useMemo((): SearchClient => {
|
|
84
170
|
const from = `${props.api}/${locale}.json`;
|
|
85
171
|
const content = staticClient({ locale, from });
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
172
|
+
const refsById = new Map<string, { id: string; version?: string }>();
|
|
173
|
+
for (const entry of scopePairs) {
|
|
174
|
+
const [sec, id] = entry.split(':');
|
|
175
|
+
if (!refsById.has(id)) {
|
|
176
|
+
refsById.set(
|
|
177
|
+
id,
|
|
178
|
+
sec === section && id === dataset && locationDataset
|
|
179
|
+
? { id, version }
|
|
180
|
+
: { id },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
98
184
|
const extra = getSearchClientSources().map((make) =>
|
|
99
|
-
make({ refs, locale, tag: undefined }),
|
|
185
|
+
make({ refs: [...refsById.values()], locale, tag: undefined }),
|
|
100
186
|
);
|
|
101
187
|
return mergeClients(content, ...extra);
|
|
102
|
-
}, [locale,
|
|
188
|
+
}, [locale, scopePairs, section, dataset, locationDataset, version, props.api]);
|
|
103
189
|
const { search, setSearch, query } = useDocsSearch({ client });
|
|
104
190
|
const showBadges =
|
|
105
|
-
|
|
106
|
-
new Set([...
|
|
191
|
+
everywhere ||
|
|
192
|
+
new Set([...scopePairs].map((entry) => entry.split(':')[0]!)).size > 1;
|
|
107
193
|
const items = useMemo(() => {
|
|
108
194
|
const list = query.data !== 'empty' ? (query.data ?? []) : [];
|
|
109
195
|
const inScope = (item: (typeof list)[number]) =>
|
|
110
|
-
|
|
196
|
+
everywhere ||
|
|
111
197
|
!('url' in item) ||
|
|
112
|
-
[...
|
|
198
|
+
[...scopePairs].some((entry) =>
|
|
113
199
|
item.url.startsWith(`/${entry.replace(':', '/')}`),
|
|
114
200
|
);
|
|
115
201
|
const hitFilter =
|
|
@@ -122,7 +208,7 @@ export function SearchDialog(props: SharedProps & { api: string }) {
|
|
|
122
208
|
return list.filter(
|
|
123
209
|
(item) => inScope(item) && (hitFilter?.(item) ?? true),
|
|
124
210
|
);
|
|
125
|
-
}, [query.data, section,
|
|
211
|
+
}, [query.data, section, everywhere, scopePairs]);
|
|
126
212
|
|
|
127
213
|
return (
|
|
128
214
|
<SearchDialogRoot search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
|
|
@@ -156,67 +242,40 @@ export function SearchDialog(props: SharedProps & { api: string }) {
|
|
|
156
242
|
)}
|
|
157
243
|
<SearchDialogFooter>
|
|
158
244
|
<div className="flex flex-wrap items-center gap-1">
|
|
245
|
+
<ScopeDropdown
|
|
246
|
+
label={t('Sections', { note: 'ui.search_sections' })}
|
|
247
|
+
allLabel={t('All', { note: 'ui.search_all' })}
|
|
248
|
+
items={sections.map(([sec]) => ({
|
|
249
|
+
id: sec,
|
|
250
|
+
title: sectionNames[sec]?.title ?? sec,
|
|
251
|
+
}))}
|
|
252
|
+
picked={sel.secs}
|
|
253
|
+
onToggle={(id) =>
|
|
254
|
+
setSel({ ...sel, secs: toggled(sel.secs, id) })
|
|
255
|
+
}
|
|
256
|
+
onAll={() => setSel({ ...sel, secs: new Set() })}
|
|
257
|
+
/>
|
|
258
|
+
<ScopeDropdown
|
|
259
|
+
label={t('Datasets', { note: 'ui.search_datasets' })}
|
|
260
|
+
allLabel={t('All', { note: 'ui.search_all' })}
|
|
261
|
+
items={datasetOptions.map(({ id, title }) => ({
|
|
262
|
+
id,
|
|
263
|
+
title: title ?? id,
|
|
264
|
+
}))}
|
|
265
|
+
picked={sel.dss}
|
|
266
|
+
onToggle={(id) =>
|
|
267
|
+
setSel({ ...sel, dss: toggled(sel.dss, id) })
|
|
268
|
+
}
|
|
269
|
+
onAll={() => setSel({ ...sel, dss: new Set() })}
|
|
270
|
+
/>
|
|
159
271
|
<button
|
|
160
272
|
type="button"
|
|
161
|
-
aria-pressed={
|
|
162
|
-
className={cn(chip,
|
|
163
|
-
onClick={() => setSel(
|
|
273
|
+
aria-pressed={everywhere}
|
|
274
|
+
className={cn(chip, everywhere ? chipOn : chipOff)}
|
|
275
|
+
onClick={() => setSel(EMPTY_SEL)}
|
|
164
276
|
>
|
|
165
277
|
{t('Everywhere', { note: 'ui.search_everywhere' })}
|
|
166
278
|
</button>
|
|
167
|
-
<span aria-hidden className="mx-0.5 h-4 w-px bg-border" />
|
|
168
|
-
{sections.map(([sec, datasets]) => {
|
|
169
|
-
const ids = datasets.map(({ id }) => `${sec}:${id}`);
|
|
170
|
-
if (ids.length === 1) {
|
|
171
|
-
const id = ids[0]!;
|
|
172
|
-
const on = sel.has(id);
|
|
173
|
-
return (
|
|
174
|
-
<button
|
|
175
|
-
key={sec}
|
|
176
|
-
type="button"
|
|
177
|
-
aria-pressed={on}
|
|
178
|
-
className={cn(chip, on ? chipOn : chipOff)}
|
|
179
|
-
onClick={() => toggle(ids)}
|
|
180
|
-
>
|
|
181
|
-
{sectionNames[sec]?.title ?? sec}
|
|
182
|
-
</button>
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
const onCount = ids.filter((id) => sel.has(id)).length;
|
|
186
|
-
return [
|
|
187
|
-
<button
|
|
188
|
-
key={sec}
|
|
189
|
-
type="button"
|
|
190
|
-
aria-pressed={onCount > 0}
|
|
191
|
-
className={cn(
|
|
192
|
-
chip,
|
|
193
|
-
onCount === ids.length
|
|
194
|
-
? chipOn
|
|
195
|
-
: onCount > 0
|
|
196
|
-
? chipMixed
|
|
197
|
-
: chipOff,
|
|
198
|
-
)}
|
|
199
|
-
onClick={() => toggle(ids)}
|
|
200
|
-
>
|
|
201
|
-
{sectionNames[sec]?.title ?? sec}
|
|
202
|
-
</button>,
|
|
203
|
-
...datasets.map(({ id, title }) => {
|
|
204
|
-
const entry = `${sec}:${id}`;
|
|
205
|
-
const on = sel.has(entry);
|
|
206
|
-
return (
|
|
207
|
-
<button
|
|
208
|
-
key={entry}
|
|
209
|
-
type="button"
|
|
210
|
-
aria-pressed={on}
|
|
211
|
-
className={cn(chip, on ? chipOn : chipOff)}
|
|
212
|
-
onClick={() => toggle([entry])}
|
|
213
|
-
>
|
|
214
|
-
{title ?? id}
|
|
215
|
-
</button>
|
|
216
|
-
);
|
|
217
|
-
}),
|
|
218
|
-
];
|
|
219
|
-
})}
|
|
220
279
|
</div>
|
|
221
280
|
</SearchDialogFooter>
|
|
222
281
|
</SearchDialogContent>
|