@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/search",
3
- "version": "1.2.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.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
- const chipMixed = cn(chipOff, 'border-dashed');
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((): Set<string> => {
62
- if (section && dataset) return new Set([`${section}:${dataset}`]);
63
- if (section)
64
- return new Set(
65
- (catalog[section] ?? []).map(({ id }) => `${section}:${id}`),
66
- );
67
- return new Set();
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: Set<string>) =>
144
+ const setSel = (value: ScopeSel) =>
72
145
  setPicked({ key: scopeKey, sel: value });
73
- const toggle = (ids: readonly string[]) => {
74
- const next = new Set(sel);
75
- const allOn = ids.every((id) => next.has(id));
76
- for (const id of ids) {
77
- if (allOn) next.delete(id);
78
- else next.add(id);
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
- setSel(next);
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 selSecs = new Set([...sel].map((entry) => entry.split(':')[0]!));
87
- const scopedSections =
88
- sel.size === 0
89
- ? dumpSectionKeys()
90
- : dumpSectionKeys().filter((sec) => selSecs.has(sec));
91
- const refs = scopedSections.flatMap((sec) =>
92
- (catalog[sec] ?? []).map(({ id }) =>
93
- sec === section && id === dataset && locationDataset
94
- ? { id, version }
95
- : { id },
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, sel, section, dataset, locationDataset, version, catalog, props.api]);
188
+ }, [locale, scopePairs, section, dataset, locationDataset, version, props.api]);
103
189
  const { search, setSearch, query } = useDocsSearch({ client });
104
190
  const showBadges =
105
- sel.size === 0 ||
106
- new Set([...sel].map((entry) => entry.split(':')[0]!)).size > 1;
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
- sel.size === 0 ||
196
+ everywhere ||
111
197
  !('url' in item) ||
112
- [...sel].some((entry) =>
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, sel]);
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={sel.size === 0}
162
- className={cn(chip, sel.size === 0 ? chipOn : chipOff)}
163
- onClick={() => setSel(new Set())}
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>