@terrafirmagreg-team/ui 0.0.0-stage → 0.1.0

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.
Files changed (215) hide show
  1. package/package.json +93 -3
  2. package/src/components/blocks/button/icon-button.tsx +48 -0
  3. package/src/components/blocks/canvas/chevron-edge.tsx +134 -0
  4. package/src/components/blocks/canvas/flow-canvas.tsx +177 -0
  5. package/src/components/blocks/canvas/flow-toolbar.tsx +32 -0
  6. package/src/components/blocks/canvas/flow-viewport.tsx +243 -0
  7. package/src/components/blocks/git/github-info.tsx +77 -0
  8. package/src/components/blocks/lang/content.tsx +22 -0
  9. package/src/components/blocks/lang/flag.tsx +24 -0
  10. package/src/components/blocks/lang/link.tsx +22 -0
  11. package/src/components/blocks/lang/redirect.tsx +47 -0
  12. package/src/components/blocks/lang/switcher.tsx +53 -0
  13. package/src/components/blocks/lang/untranslated-callout.tsx +28 -0
  14. package/src/components/blocks/link/link-preview.tsx +256 -0
  15. package/src/components/blocks/list/row.tsx +91 -0
  16. package/src/components/blocks/media/glb/model.tsx +243 -0
  17. package/src/components/blocks/media/glb/scene.tsx +372 -0
  18. package/src/components/blocks/media/glb/viewer.tsx +51 -0
  19. package/src/components/blocks/media/image.tsx +135 -0
  20. package/src/components/blocks/media/slideshow.tsx +177 -0
  21. package/src/components/blocks/nav/breadcrumb.tsx +50 -0
  22. package/src/components/blocks/nav/link.tsx +85 -0
  23. package/src/components/blocks/nav/site-menu.tsx +77 -0
  24. package/src/components/blocks/nav/site-title.tsx +60 -0
  25. package/src/components/blocks/nav/switcher.tsx +205 -0
  26. package/src/components/blocks/page/empty-state.tsx +31 -0
  27. package/src/components/blocks/page/heading.tsx +53 -0
  28. package/src/components/blocks/page/pager.tsx +112 -0
  29. package/src/components/blocks/readability/menu.tsx +431 -0
  30. package/src/components/blocks/readability/track.tsx +164 -0
  31. package/src/components/blocks/search/command.tsx +79 -0
  32. package/src/components/blocks/search/dialog.tsx +712 -0
  33. package/src/components/blocks/search/provider.tsx +117 -0
  34. package/src/components/blocks/sidebar.tsx +406 -0
  35. package/src/components/blocks/theme/toggle.tsx +71 -0
  36. package/src/components/blocks/tooltip/hint.tsx +27 -0
  37. package/src/components/blocks/viewer/controls.tsx +279 -0
  38. package/src/components/blocks/viewer/float-bar.tsx +50 -0
  39. package/src/components/layouts/layout.tsx +93 -0
  40. package/src/components/layouts/left-sidebar/content.tsx +62 -0
  41. package/src/components/layouts/left-sidebar/context.tsx +54 -0
  42. package/src/components/layouts/left-sidebar/expand.tsx +22 -0
  43. package/src/components/layouts/left-sidebar/footer.tsx +105 -0
  44. package/src/components/layouts/left-sidebar/index.ts +28 -0
  45. package/src/components/layouts/left-sidebar/items.tsx +102 -0
  46. package/src/components/layouts/left-sidebar/search.tsx +74 -0
  47. package/src/components/layouts/left-sidebar/sidebar.tsx +188 -0
  48. package/src/components/layouts/left-sidebar/tree.tsx +156 -0
  49. package/src/components/layouts/right-sidebar.tsx +95 -0
  50. package/src/components/layouts/tree-context.tsx +41 -0
  51. package/src/components/mdx/accordion.tsx +184 -0
  52. package/src/components/mdx/banner.tsx +154 -0
  53. package/src/components/mdx/callout.tsx +96 -0
  54. package/src/components/mdx/card.tsx +49 -0
  55. package/src/components/mdx/codeblock.tsx +249 -0
  56. package/src/components/mdx/collapsible.tsx +32 -0
  57. package/src/components/mdx/files.tsx +62 -0
  58. package/src/components/mdx/glow-header.tsx +40 -0
  59. package/src/components/mdx/gradient-text.tsx +27 -0
  60. package/src/components/mdx/image-zoom.tsx +314 -0
  61. package/src/components/mdx/mermaid.tsx +16 -0
  62. package/src/components/mdx/multiblock.tsx +18 -0
  63. package/src/components/mdx/preview.tsx +100 -0
  64. package/src/components/mdx/steps.tsx +9 -0
  65. package/src/components/mdx/table.tsx +13 -0
  66. package/src/components/mdx/tabs.tsx +353 -0
  67. package/src/components/mdx/type-table.tsx +170 -0
  68. package/src/components/ui/accordion.tsx +71 -0
  69. package/src/components/ui/alert-dialog.tsx +187 -0
  70. package/src/components/ui/alert.tsx +75 -0
  71. package/src/components/ui/aspect-ratio.tsx +22 -0
  72. package/src/components/ui/attachment.tsx +207 -0
  73. package/src/components/ui/avatar.tsx +108 -0
  74. package/src/components/ui/badge.tsx +51 -0
  75. package/src/components/ui/breadcrumb.tsx +124 -0
  76. package/src/components/ui/bubble.tsx +127 -0
  77. package/src/components/ui/button-group.tsx +87 -0
  78. package/src/components/ui/button.tsx +57 -0
  79. package/src/components/ui/calendar.tsx +221 -0
  80. package/src/components/ui/card.tsx +102 -0
  81. package/src/components/ui/carousel.tsx +242 -0
  82. package/src/components/ui/chart.tsx +371 -0
  83. package/src/components/ui/checkbox.tsx +28 -0
  84. package/src/components/ui/collapsible.tsx +21 -0
  85. package/src/components/ui/combobox.tsx +297 -0
  86. package/src/components/ui/command.tsx +198 -0
  87. package/src/components/ui/context-menu.tsx +270 -0
  88. package/src/components/ui/dialog.tsx +160 -0
  89. package/src/components/ui/direction.tsx +6 -0
  90. package/src/components/ui/drawer.tsx +227 -0
  91. package/src/components/ui/dropdown-menu.tsx +269 -0
  92. package/src/components/ui/empty.tsx +103 -0
  93. package/src/components/ui/field.tsx +238 -0
  94. package/src/components/ui/hover-card.tsx +50 -0
  95. package/src/components/ui/input-group.tsx +158 -0
  96. package/src/components/ui/input-otp.tsx +86 -0
  97. package/src/components/ui/input.tsx +19 -0
  98. package/src/components/ui/item.tsx +201 -0
  99. package/src/components/ui/kbd.tsx +26 -0
  100. package/src/components/ui/label.tsx +19 -0
  101. package/src/components/ui/marker.tsx +70 -0
  102. package/src/components/ui/menubar.tsx +280 -0
  103. package/src/components/ui/message-scroller.tsx +131 -0
  104. package/src/components/ui/message.tsx +91 -0
  105. package/src/components/ui/native-select.tsx +60 -0
  106. package/src/components/ui/navigation-menu.tsx +167 -0
  107. package/src/components/ui/pagination.tsx +130 -0
  108. package/src/components/ui/popover.tsx +91 -0
  109. package/src/components/ui/progress.tsx +82 -0
  110. package/src/components/ui/questionnaire.tsx +324 -0
  111. package/src/components/ui/radio-group.tsx +37 -0
  112. package/src/components/ui/resizable.tsx +49 -0
  113. package/src/components/ui/scroll-area.tsx +54 -0
  114. package/src/components/ui/select.tsx +200 -0
  115. package/src/components/ui/separator.tsx +24 -0
  116. package/src/components/ui/sheet.tsx +138 -0
  117. package/src/components/ui/sidebar.tsx +730 -0
  118. package/src/components/ui/skeleton.tsx +13 -0
  119. package/src/components/ui/slider.tsx +51 -0
  120. package/src/components/ui/spinner.tsx +10 -0
  121. package/src/components/ui/switch.tsx +31 -0
  122. package/src/components/ui/table.tsx +115 -0
  123. package/src/components/ui/tabs.tsx +81 -0
  124. package/src/components/ui/textarea.tsx +17 -0
  125. package/src/components/ui/toast.tsx +234 -0
  126. package/src/components/ui/toggle-group.tsx +89 -0
  127. package/src/components/ui/toggle.tsx +44 -0
  128. package/src/components/ui/tooltip.tsx +65 -0
  129. package/src/hooks/canvas/use-flow-hover.ts +18 -0
  130. package/src/hooks/canvas/use-flow-viewport-persist.ts +33 -0
  131. package/src/hooks/lang.ts +8 -0
  132. package/src/hooks/layout.ts +18 -0
  133. package/src/hooks/page/use-fit-pages.ts +91 -0
  134. package/src/hooks/page/use-location-search.ts +25 -0
  135. package/src/hooks/page/use-page-preview.ts +40 -0
  136. package/src/hooks/page/use-paged-list.ts +20 -0
  137. package/src/hooks/readability/provider.ts +86 -0
  138. package/src/hooks/search/dialog.ts +41 -0
  139. package/src/hooks/search/provider.ts +25 -0
  140. package/src/hooks/search/use-highlight-query.ts +51 -0
  141. package/src/hooks/sidebar.ts +63 -0
  142. package/src/hooks/use-async-value.ts +57 -0
  143. package/src/hooks/use-close-on-scroll.ts +23 -0
  144. package/src/hooks/use-copy-button.ts +46 -0
  145. package/src/hooks/use-cycle-index.ts +75 -0
  146. package/src/hooks/use-document-title.ts +9 -0
  147. package/src/hooks/use-element-size.ts +35 -0
  148. package/src/hooks/use-external-meta.ts +132 -0
  149. package/src/hooks/use-hot-key.ts +33 -0
  150. package/src/hooks/use-hover-follow.ts +54 -0
  151. package/src/hooks/use-in-view.ts +30 -0
  152. package/src/hooks/use-last-value.ts +15 -0
  153. package/src/hooks/use-marquee.ts +51 -0
  154. package/src/hooks/use-media-query.ts +14 -0
  155. package/src/hooks/use-mobile.ts +19 -0
  156. package/src/hooks/use-modifier-keys.ts +31 -0
  157. package/src/hooks/use-on-change.ts +35 -0
  158. package/src/hooks/use-pane-keys.ts +28 -0
  159. package/src/hooks/use-swipe.ts +26 -0
  160. package/src/hooks/use-tree.ts +102 -0
  161. package/src/hooks/viewer/use-in-float-bar.ts +6 -0
  162. package/src/hooks/viewer/use-viewer-prefs.ts +77 -0
  163. package/src/lib/canvas/edge.ts +173 -0
  164. package/src/lib/canvas/geometry.ts +53 -0
  165. package/src/lib/canvas/state.ts +47 -0
  166. package/src/lib/canvas/viewport.ts +37 -0
  167. package/src/lib/canvas/xyflow.ts +1 -0
  168. package/src/lib/config.ts +81 -0
  169. package/src/lib/mdx/callout.ts +20 -0
  170. package/src/lib/mdx/gradient.ts +15 -0
  171. package/src/lib/mdx/mermaid.ts +14 -0
  172. package/src/lib/media/crop-opaque.ts +76 -0
  173. package/src/lib/media/nearby-indexes.ts +6 -0
  174. package/src/lib/media/trim-transparent.ts +117 -0
  175. package/src/lib/readability/config.ts +10 -0
  176. package/src/lib/readability/spotlight.ts +186 -0
  177. package/src/lib/readability/store.ts +146 -0
  178. package/src/lib/tree.ts +92 -0
  179. package/src/lib/utils/async-cache.ts +32 -0
  180. package/src/lib/utils/cn.ts +1 -0
  181. package/src/lib/utils/format.ts +22 -0
  182. package/src/lib/utils/history.ts +9 -0
  183. package/src/lib/utils/merge-refs.ts +13 -0
  184. package/src/lib/utils/nav-active.ts +58 -0
  185. package/src/lib/utils/stored-value.ts +136 -0
  186. package/src/lib/utils/urls.ts +17 -0
  187. package/src/locales/en_us.json +119 -0
  188. package/src/locales/pl_pl.json +35 -0
  189. package/src/locales/ru_ru.json +119 -0
  190. package/src/locales/uk_ua.json +35 -0
  191. package/src/locales/zh_cn.json +17 -0
  192. package/src/locales/zh_tw.json +17 -0
  193. package/src/locales.ts +5 -0
  194. package/src/provider.tsx +141 -0
  195. package/src/types/canvas.ts +49 -0
  196. package/src/types/glb.ts +25 -0
  197. package/src/types/nav.ts +95 -0
  198. package/src/types/preview.ts +9 -0
  199. package/src/types/readability.ts +28 -0
  200. package/src/types/search.ts +56 -0
  201. package/src/types/tree.ts +18 -0
  202. package/src/types/viewer.ts +53 -0
  203. package/styles/content/base.css +194 -0
  204. package/styles/content/default-colors.css +51 -0
  205. package/styles/content/shiki.css +108 -0
  206. package/styles/content/typography.mjs +3941 -0
  207. package/styles/content.css +7 -0
  208. package/styles/image-zoom.css +83 -0
  209. package/styles/layout.css +28 -0
  210. package/styles/left-sidebar.css +140 -0
  211. package/styles/readability.css +50 -0
  212. package/styles/right-sidebar.css +6 -0
  213. package/styles/workspace.css +26 -0
  214. package/styles.css +413 -0
  215. package/README.md +0 -3
@@ -0,0 +1,712 @@
1
+ 'use client';
2
+
3
+ import { ChevronRight, CircleAlert, Hash, SearchIcon } from 'lucide-react';
4
+ import {
5
+ type ComponentProps,
6
+ Fragment,
7
+ type ReactNode,
8
+ use,
9
+ useCallback,
10
+ useEffect,
11
+ useEffectEvent,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useSyncExternalStore,
16
+ } from 'react';
17
+ import { T } from '@terrafirmagreg-team/translate';
18
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
19
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
20
+ import { Dialog } from '@base-ui/react/dialog';
21
+ import { useHighlightQuery } from '@terrafirmagreg-team/ui/hooks/search/use-highlight-query';
22
+ import {
23
+ SearchDialogContext,
24
+ SearchTagsListContext,
25
+ useSearch,
26
+ useSearchTagsList,
27
+ } from '@terrafirmagreg-team/ui/hooks/search/dialog';
28
+ import type {
29
+ ReactSortedResult,
30
+ SearchItemType,
31
+ SearchResult,
32
+ SearchResultItem,
33
+ SearchResultRecord,
34
+ SearchResultTable,
35
+ SharedProps,
36
+ } from '@terrafirmagreg-team/ui/types/search';
37
+ import { type Options, toJsxRuntime } from 'hast-util-to-jsx-runtime';
38
+ import * as JsxRuntime from 'react/jsx-runtime';
39
+ import { cva } from 'class-variance-authority';
40
+ import { useRouter } from 'next/navigation';
41
+ import scrollIntoView from 'scroll-into-view-if-needed';
42
+ import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
43
+ import { mergeRefs } from '@terrafirmagreg-team/ui/lib/utils/merge-refs';
44
+
45
+ type ItemRenderer = (props: {
46
+ item: SearchItemType;
47
+ table?: SearchResultTable;
48
+ onClick: () => void;
49
+ }) => ReactNode;
50
+
51
+ interface TableProps {
52
+ table: SearchResultTable;
53
+ Item: ItemRenderer;
54
+ onSelect: (item: SearchItemType) => void;
55
+ }
56
+
57
+ export type { SearchItemType, SharedProps } from '@terrafirmagreg-team/ui/types/search';
58
+
59
+ export interface SearchDialogProps extends SharedProps {
60
+ search: string;
61
+ onSearchChange: (v: string) => void;
62
+ onSelect?: (item: SearchItemType) => void;
63
+ isLoading?: boolean;
64
+
65
+ data?: SearchResult;
66
+
67
+ error?: Error;
68
+
69
+ children: ReactNode;
70
+ }
71
+
72
+ const mdComponents = {
73
+
74
+ mark(props: ComponentProps<'mark'>) {
75
+ return <span {...props} className="text-primary underline" />;
76
+ },
77
+ a: 'span',
78
+ p(props: ComponentProps<'p'>) {
79
+ return <p {...props} className="min-w-0" />;
80
+ },
81
+ custom({
82
+ tagName,
83
+ children,
84
+ ...rest
85
+ }: Record<string, unknown> & { tagName: string; children?: ReactNode }) {
86
+ return (
87
+ <span className="inline-flex max-w-full items-center border p-0.5 rounded-md bg-card text-card-foreground divide-x divide-border">
88
+ <code
89
+ data-highlight-ignore=""
90
+ className="not-prose rounded-sm px-0.5 me-1 bg-primary font-medium text-xs text-primary-foreground border-none"
91
+ >
92
+ {tagName}
93
+ </code>
94
+ {Object.entries(rest).map(([k, v]) => {
95
+ if (typeof v !== 'string') return;
96
+
97
+ return (
98
+ <code
99
+ key={k}
100
+ data-highlight-ignore=""
101
+ className="not-prose truncate text-xs text-muted-foreground px-1"
102
+ >
103
+ <span className="text-card-foreground">{k}: </span>
104
+ {v}
105
+ </code>
106
+ );
107
+ })}
108
+ {children && <span className="ps-1">{children}</span>}
109
+ </span>
110
+ );
111
+ },
112
+ pre(props: ComponentProps<'pre'>) {
113
+ return (
114
+ <pre
115
+ {...props}
116
+ className={cn(
117
+ 'not-prose flex flex-col border rounded-md my-0.5 p-2 bg-secondary text-secondary-foreground max-h-20 overflow-hidden *:mask-[linear-gradient(to_bottom,white,white_30px,transparent_80px)]',
118
+ props.className,
119
+ )}
120
+ />
121
+ );
122
+ },
123
+ };
124
+
125
+ const renderOptions: Options = { development: false, components: mdComponents, ...JsxRuntime };
126
+
127
+ function Children({ children }: { children?: ReactNode }) {
128
+ return children;
129
+ }
130
+
131
+ function TableCell(props: ComponentProps<'div'>) {
132
+ return <div {...props} className="min-w-0 not-last:truncate last:line-clamp-2" />;
133
+ }
134
+
135
+ const tableRenderOptions: Options = {
136
+ ...renderOptions,
137
+ components: {
138
+ ...mdComponents,
139
+ table: Children,
140
+ thead: Children,
141
+ tbody: Children,
142
+ tr: Children,
143
+ th: TableCell,
144
+ td: TableCell,
145
+ },
146
+ };
147
+
148
+ const rowRenderOptions: Options = {
149
+ ...tableRenderOptions,
150
+ components: { ...tableRenderOptions.components, thead: () => null },
151
+ };
152
+
153
+ export function SearchDialog({
154
+ open,
155
+ onOpenChange,
156
+ search,
157
+ onSearchChange,
158
+ isLoading = false,
159
+ data,
160
+ error,
161
+ onSelect: onSelectProp,
162
+ children,
163
+ dialogHandle,
164
+ }: SearchDialogProps) {
165
+ const router = useRouter();
166
+ const onOpenChangeCallback = useRef(onOpenChange);
167
+ onOpenChangeCallback.current = onOpenChange;
168
+ const onSearchChangeCallback = useRef(onSearchChange);
169
+ onSearchChangeCallback.current = onSearchChange;
170
+ const onSelect = (item: SearchItemType) => {
171
+ if (item.type === 'action') {
172
+ item.onSelect();
173
+ } else if (item.external) {
174
+ window.open(item.url, '_blank')?.focus();
175
+ } else {
176
+ router.push(item.url);
177
+ }
178
+
179
+ onOpenChange(false);
180
+ onSelectProp?.(item);
181
+ };
182
+ const onSelectCallback = useRef(onSelect);
183
+ onSelectCallback.current = onSelect;
184
+
185
+ const activeItem = useMemo(() => {
186
+ let active: string | null = null;
187
+ const listeners = new Set<() => void>();
188
+
189
+ return {
190
+ getActive: () => active,
191
+ setActive(id: string | null) {
192
+ active = id;
193
+ for (const listener of listeners) listener();
194
+ },
195
+ subscribeActive(listener: () => void) {
196
+ listeners.add(listener);
197
+ return () => void listeners.delete(listener);
198
+ },
199
+ };
200
+ }, []);
201
+
202
+ return (
203
+ <Dialog.Root open={open} onOpenChange={onOpenChange} handle={dialogHandle}>
204
+ <SearchDialogContext
205
+ value={useMemo(
206
+ () => ({
207
+ ...activeItem,
208
+ open,
209
+ search,
210
+ isLoading,
211
+ data,
212
+ error,
213
+ query: data ? data.query : search,
214
+ onOpenChange: (v) => onOpenChangeCallback.current(v),
215
+ onSearchChange: (v) => onSearchChangeCallback.current(v),
216
+ onSelect: (v) => onSelectCallback.current(v),
217
+ }),
218
+ [activeItem, isLoading, open, search, data, error],
219
+ )}
220
+ >
221
+ {children}
222
+ </SearchDialogContext>
223
+ </Dialog.Root>
224
+ );
225
+ }
226
+
227
+ export function SearchDialogHeader(props: ComponentProps<'div'>) {
228
+ return <div {...props} className={cn('flex flex-row items-center gap-2 p-3', props.className)} />;
229
+ }
230
+
231
+ export function SearchDialogInput(props: ComponentProps<'input'>) {
232
+ const t = useTranslations();
233
+ const { search, onSearchChange, getActive, subscribeActive } = useSearch();
234
+ const active = useSyncExternalStore(subscribeActive, getActive, getActive);
235
+
236
+ return (
237
+ <input
238
+ data-search-dialog-input=""
239
+ role="combobox"
240
+ aria-expanded={active !== null}
241
+ aria-activedescendant={active !== null ? `search-option-${active}` : undefined}
242
+ aria-label={t('ui.search_dialog')}
243
+ aria-autocomplete="list"
244
+ aria-controls="search-list"
245
+ value={search}
246
+ onChange={(e) => onSearchChange(e.target.value)}
247
+ placeholder={t('ui.search_dialog')}
248
+ {...props}
249
+ className={cn(
250
+ 'w-0 flex-1 bg-transparent text-lg placeholder:text-muted-foreground focus-visible:outline-none',
251
+ props.className,
252
+ )}
253
+ />
254
+ );
255
+ }
256
+
257
+ export function SearchDialogClose({
258
+ children = 'ESC',
259
+ className,
260
+ ...props
261
+ }: ComponentProps<'button'>) {
262
+ const { onOpenChange } = useSearch();
263
+ const t = useTranslations();
264
+
265
+ return (
266
+ <button
267
+ type="button"
268
+ aria-label={t('ui.search_close')}
269
+ onClick={() => onOpenChange(false)}
270
+ className={cn(
271
+ buttonVariants({
272
+ variant: 'outline',
273
+ size: 'sm',
274
+ className: 'font-mono text-muted-foreground',
275
+ }),
276
+ className,
277
+ )}
278
+ {...props}
279
+ >
280
+ {children}
281
+ </button>
282
+ );
283
+ }
284
+
285
+ export function SearchDialogFooter(props: ComponentProps<'div'>) {
286
+ return <div {...props} className={cn('bg-secondary/50 p-3 empty:hidden', props.className)} />;
287
+ }
288
+
289
+ export function SearchDialogOverlay({
290
+ className,
291
+ ...props
292
+ }: ComponentProps<typeof Dialog.Backdrop>) {
293
+ return (
294
+ <Dialog.Backdrop
295
+ {...props}
296
+ className={(s) =>
297
+ cn(
298
+ 'fixed inset-0 z-50 backdrop-blur-xs bg-overlay data-open:animate-fade-in data-closed:animate-fade-out',
299
+ typeof className === 'function' ? className(s) : className,
300
+ )
301
+ }
302
+ />
303
+ );
304
+ }
305
+
306
+ export function SearchDialogContent({
307
+ ref,
308
+ children,
309
+ className,
310
+ ...props
311
+ }: ComponentProps<typeof Dialog.Popup>) {
312
+ const t = useTranslations();
313
+ const localRef = useRef<HTMLDivElement>(null);
314
+
315
+ return (
316
+ <Dialog.Portal>
317
+ <Dialog.Popup
318
+ id="search-dialog-content"
319
+ ref={mergeRefs(ref, localRef)}
320
+ aria-describedby={undefined}
321
+ initialFocus={(s) => {
322
+ const input = localRef.current?.querySelector<HTMLInputElement>(
323
+ 'input[data-search-dialog-input]',
324
+ );
325
+ if (s === 'touch') {
326
+ input?.focus({ preventScroll: true });
327
+ return false;
328
+ }
329
+ return input;
330
+ }}
331
+ className={(s) =>
332
+ cn(
333
+ 'fixed left-1/2 top-4 md:top-[calc(50%_-_250px)] z-50 w-[calc(100%_-_1rem)] max-w-screen-sm -translate-x-1/2 rounded-xl border bg-popover text-popover-foreground shadow-2xl overflow-hidden data-closed:animate-dialog-out data-open:animate-dialog-in focus-visible:outline-none',
334
+ '*:border-b *:has-[+:last-child[data-empty=true]]:border-b-0 *:data-[empty=true]:border-b-0 *:last:border-b-0',
335
+ typeof className === 'function' ? className(s) : className,
336
+ )
337
+ }
338
+ {...props}
339
+ >
340
+ <Dialog.Title className="hidden">{t('ui.search_dialog')}</Dialog.Title>
341
+ {children}
342
+ </Dialog.Popup>
343
+ </Dialog.Portal>
344
+ );
345
+ }
346
+
347
+ export function SearchDialogList({
348
+ items: itemsProp,
349
+ defaultItems = null,
350
+ Empty = () => (
351
+ <div role="status" className="py-12 text-center text-sm text-muted-foreground">
352
+ <T text="ui.search_empty" />
353
+ </div>
354
+ ),
355
+ Item = (props) => <SearchDialogListItem {...props} />,
356
+ Table = (props) => <SearchDialogListTable {...props} />,
357
+ ...props
358
+ }: Omit<ComponentProps<'div'>, 'children'> & {
359
+
360
+ items?: (SearchItemType | SearchResultItem)[] | null;
361
+
362
+ defaultItems?: (SearchItemType | SearchResultItem)[] | null;
363
+
364
+ Empty?: () => ReactNode;
365
+
366
+ Item?: ItemRenderer;
367
+
368
+ Table?: (props: TableProps) => ReactNode;
369
+ }) {
370
+ const ref = useRef<HTMLDivElement>(null);
371
+ const t = useTranslations();
372
+ const { onSelect, data, error, getActive, setActive } = useSearch();
373
+ const items = itemsProp === undefined ? (data?.items ?? defaultItems) : itemsProp;
374
+
375
+ useLayoutEffect(
376
+ () => setActive(error ? null : (items?.[0]?.id ?? null)),
377
+ [setActive, items, error],
378
+ );
379
+
380
+ const onKey = useEffectEvent((e: KeyboardEvent) => {
381
+ if (!items || e.isComposing || e.keyCode === 229) return;
382
+ const list = flattenItems(items);
383
+
384
+ if (e.key === 'ArrowDown' || e.key == 'ArrowUp') {
385
+ let idx = list.findIndex((item) => item.id === getActive());
386
+ if (idx === -1) idx = 0;
387
+ else if (e.key === 'ArrowDown') idx++;
388
+ else idx--;
389
+
390
+ setActive(list.at(idx % list.length)?.id ?? null);
391
+ e.preventDefault();
392
+ }
393
+
394
+ if (e.key === 'Enter') {
395
+ const selected = list.find((item) => item.id === getActive());
396
+
397
+ if (selected) onSelect(selected);
398
+ e.preventDefault();
399
+ }
400
+ });
401
+
402
+ useEffect(() => {
403
+ const element = ref.current;
404
+ if (!element) return;
405
+
406
+ const observer = new ResizeObserver(() => {
407
+ let height = 0;
408
+ for (const child of element.children) height += (child as HTMLElement).offsetHeight;
409
+
410
+ element.style.setProperty('--animated-height', `${height}px`);
411
+ });
412
+
413
+ for (const child of element.children) observer.observe(child);
414
+
415
+ const content: Pick<Window, 'addEventListener' | 'removeEventListener'> =
416
+ document.getElementById('search-dialog-content') ?? window;
417
+ content.addEventListener('keydown', onKey);
418
+ return () => {
419
+ observer.disconnect();
420
+ content.removeEventListener('keydown', onKey);
421
+ };
422
+ }, []);
423
+
424
+ return (
425
+ <div
426
+ {...props}
427
+ ref={ref}
428
+ data-empty={items === null && !error}
429
+ className={cn(
430
+ 'overflow-hidden h-(--animated-height) transition-[height] [&_::highlight(search-hit)]:text-primary [&_::highlight(search-hit)]:underline',
431
+ props.className,
432
+ )}
433
+ >
434
+ <p
435
+ role="status"
436
+ className={cn('truncate text-sm not-empty:p-3', items && 'not-empty:border-b')}
437
+ >
438
+ {error && (
439
+ <>
440
+ <CircleAlert className="inline size-5 me-2 align-top text-error" />
441
+ {data ? (
442
+ <T
443
+ text="ui.search_failed_results"
444
+ variables={{
445
+ query: <span className="font-medium">{data.query}</span>,
446
+ }}
447
+ />
448
+ ) : (
449
+ <T text="ui.search_failed" />
450
+ )}
451
+ </>
452
+ )}
453
+ </p>
454
+ <div
455
+ id="search-list"
456
+
457
+ role={items?.length ? 'listbox' : undefined}
458
+ aria-label={items?.length ? t('ui.search_dialog') : undefined}
459
+ className={cn('w-full flex flex-col overflow-y-auto max-h-[460px] p-1', !items && 'hidden')}
460
+ >
461
+ {items?.length === 0 && Empty()}
462
+
463
+ {items?.map((item) => (
464
+ <Fragment key={item.id}>
465
+ {item.type === 'table'
466
+ ? Table({ table: item, Item, onSelect })
467
+ : Item({ item, onClick: () => onSelect(item) })}
468
+ </Fragment>
469
+ ))}
470
+ </div>
471
+ </div>
472
+ );
473
+ }
474
+
475
+ function flattenItems(items: (SearchItemType | SearchResultItem)[]): SearchItemType[] {
476
+ const out: SearchItemType[] = [];
477
+ for (const item of items) {
478
+ if (item.type === 'table') out.push(...item.rows);
479
+ else out.push(item);
480
+ }
481
+
482
+ return out;
483
+ }
484
+
485
+ export function SearchDialogListTable({ table, Item, onSelect }: TableProps) {
486
+ const { query } = useSearch();
487
+ const highlightRef = useHighlightQuery(query);
488
+ const headerId = `search-table-${table.id}`;
489
+ const header = useMemo(
490
+ () => table.header && toJsxRuntime(table.header, tableRenderOptions),
491
+ [table.header],
492
+ );
493
+
494
+ return (
495
+ <div
496
+
497
+ role={table.header ? 'group' : undefined}
498
+ aria-labelledby={table.header ? headerId : undefined}
499
+ className="ms-3 shrink-0 border-s py-2 ps-3.25 pe-2.5"
500
+ >
501
+ <div
502
+ className="grid gap-x-3 overflow-hidden rounded-lg border bg-card text-sm"
503
+ style={{
504
+ gridTemplateColumns: `${'fit-content(30%) '.repeat(table.columns - 1)}minmax(30%, 1fr)`,
505
+ }}
506
+ >
507
+ {header && (
508
+ <div
509
+ id={headerId}
510
+ ref={highlightRef}
511
+ aria-hidden
512
+ className="col-span-full grid grid-cols-subgrid bg-secondary px-3 py-1 text-xs text-muted-foreground"
513
+ >
514
+ {header}
515
+ </div>
516
+ )}
517
+ {table.rows.map((row) => (
518
+ <Fragment key={row.id}>
519
+ {Item({ item: row, table, onClick: () => onSelect(row) })}
520
+ </Fragment>
521
+ ))}
522
+ </div>
523
+ </div>
524
+ );
525
+ }
526
+
527
+ export function SearchDialogListItem({
528
+ item,
529
+ table,
530
+ className,
531
+ children,
532
+ renderMarkdown,
533
+ ...props
534
+ }: ComponentProps<'div'> & {
535
+
536
+ table?: SearchResultTable;
537
+
538
+ renderMarkdown?: (v: string) => ReactNode;
539
+ item: SearchItemType;
540
+ }) {
541
+ const { query, getActive, setActive, subscribeActive } = useSearch();
542
+ const isActive = () => getActive() === item.id;
543
+ const active = useSyncExternalStore(subscribeActive, isActive, isActive);
544
+ const highlightRef = useHighlightQuery(query);
545
+ const content = useMemo(() => {
546
+ if (item.type === 'action') return item.node;
547
+ if (renderMarkdown && typeof item.content === 'string') return renderMarkdown(item.content);
548
+ if (!('hastContent' in item)) return item.content;
549
+ if (!table) return toJsxRuntime(item.hastContent, renderOptions);
550
+ return toJsxRuntime(item.hastContent, table.header ? rowRenderOptions : tableRenderOptions);
551
+ }, [item, renderMarkdown, table]);
552
+
553
+ if (item.type === 'action') {
554
+ children ??= content;
555
+ } else if (table) {
556
+ children ??= (
557
+ <div
558
+ ref={highlightRef}
559
+ className={cn(
560
+ 'contents',
561
+
562
+ !table.header &&
563
+ '*:first:font-medium *:first:text-primary *:nth-2:text-xs *:not-first:text-muted-foreground',
564
+ )}
565
+ >
566
+ {content}
567
+ </div>
568
+ );
569
+ } else {
570
+ children ??= (
571
+ <>
572
+ <div className="inline-flex items-center text-muted-foreground text-xs empty:hidden">
573
+ {item.breadcrumbs?.map((item, i) => (
574
+ <Fragment key={i}>
575
+ {i > 0 && <ChevronRight className="size-4 rtl:rotate-180" />}
576
+ {item}
577
+ </Fragment>
578
+ ))}
579
+ </div>
580
+
581
+ {item.type !== 'page' && (
582
+ <div role="none" className="absolute inset-s-3 inset-y-0 w-px bg-border" />
583
+ )}
584
+ {item.type === 'heading' && (
585
+ <Hash className="absolute inset-s-6 top-2.5 size-4 text-muted-foreground" />
586
+ )}
587
+ <div
588
+ ref={highlightRef}
589
+ className={cn(
590
+ 'prose prose-sm min-w-0',
591
+ item.type === 'text' && 'ps-4',
592
+ item.type === 'heading' && 'ps-8',
593
+ item.type === 'page' || item.type === 'heading'
594
+ ? 'font-medium text-inherit'
595
+ : 'text-popover-foreground/80',
596
+ )}
597
+ >
598
+ {content}
599
+ </div>
600
+ </>
601
+ );
602
+ }
603
+
604
+ return (
605
+ <div
606
+ id={`search-option-${item.id}`}
607
+ role="option"
608
+ ref={useCallback(
609
+ (element: HTMLDivElement | null) => {
610
+ if (active && element) {
611
+ scrollIntoView(element, {
612
+ scrollMode: 'if-needed',
613
+ block: 'nearest',
614
+ boundary: element.closest('[role="listbox"]'),
615
+ });
616
+ }
617
+ },
618
+ [active],
619
+ )}
620
+ aria-selected={active}
621
+ className={cn(
622
+ 'cursor-default',
623
+ table
624
+ ? 'col-span-full grid grid-cols-subgrid items-baseline px-3 py-1.5 not-first:border-t'
625
+ : 'relative shrink-0 px-2.5 py-2 text-sm overflow-hidden rounded-lg',
626
+ active && 'bg-accent text-accent-foreground',
627
+ className,
628
+ )}
629
+ onPointerMove={() => setActive(item.id)}
630
+ {...props}
631
+ >
632
+ {children}
633
+ </div>
634
+ );
635
+ }
636
+
637
+ export function SearchDialogIcon(props: ComponentProps<'svg'>) {
638
+ const { isLoading } = useSearch();
639
+
640
+ return (
641
+ <SearchIcon
642
+ {...props}
643
+ className={cn(
644
+ 'size-5 text-muted-foreground',
645
+ isLoading && 'animate-pulse duration-400',
646
+ props.className,
647
+ )}
648
+ />
649
+ );
650
+ }
651
+
652
+ export interface SearchTagsListProps extends ComponentProps<'div'> {
653
+ tag?: string;
654
+ onTagChange: (tag: string | undefined) => void;
655
+ allowClear?: boolean;
656
+ }
657
+
658
+ const itemVariants = cva(
659
+ 'rounded-md border px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors',
660
+ {
661
+ variants: {
662
+ active: {
663
+ true: 'bg-accent text-accent-foreground',
664
+ },
665
+ },
666
+ },
667
+ );
668
+ export function SearchTagsList({ tag, onTagChange, allowClear = false, ...props }: SearchTagsListProps) {
669
+ const onTagChangeCallback = useRef(onTagChange);
670
+ onTagChangeCallback.current = onTagChange;
671
+ return (
672
+ <div {...props} className={cn('flex items-center gap-1 flex-wrap', props.className)}>
673
+ <SearchTagsListContext
674
+ value={useMemo(
675
+ () => ({
676
+ value: tag,
677
+ onValueChange: (v) => onTagChangeCallback.current(v),
678
+ allowClear,
679
+ }),
680
+ [allowClear, tag],
681
+ )}
682
+ >
683
+ {props.children}
684
+ </SearchTagsListContext>
685
+ </div>
686
+ );
687
+ }
688
+
689
+ export function SearchTagsListItem({
690
+ value,
691
+ className,
692
+ ...props
693
+ }: ComponentProps<'button'> & {
694
+ value: string;
695
+ }) {
696
+ const { onValueChange, value: selectedValue, allowClear } = useSearchTagsList();
697
+ const selected = value === selectedValue;
698
+
699
+ return (
700
+ <button
701
+ type="button"
702
+ data-active={selected}
703
+ className={cn(itemVariants({ active: selected, className }))}
704
+ onClick={() => onValueChange(selected && allowClear ? undefined : value)}
705
+ tabIndex={-1}
706
+ {...props}
707
+ >
708
+ {props.children}
709
+ </button>
710
+ );
711
+ }
712
+