@terrafirmagreg-team/ui 0.0.0-stage → 1.2.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 (225) hide show
  1. package/package.json +93 -3
  2. package/src/components/blocks/button/icon-button.tsx +49 -0
  3. package/src/components/blocks/canvas/chevron-edge.tsx +242 -0
  4. package/src/components/blocks/canvas/flow-canvas.tsx +181 -0
  5. package/src/components/blocks/canvas/flow-toolbar.tsx +33 -0
  6. package/src/components/blocks/canvas/flow-viewport.tsx +244 -0
  7. package/src/components/blocks/fit-box.tsx +26 -0
  8. package/src/components/blocks/git/github-info.tsx +78 -0
  9. package/src/components/blocks/lang/content.tsx +52 -0
  10. package/src/components/blocks/lang/flag.tsx +25 -0
  11. package/src/components/blocks/lang/link.tsx +23 -0
  12. package/src/components/blocks/lang/redirect.tsx +48 -0
  13. package/src/components/blocks/lang/switcher.tsx +54 -0
  14. package/src/components/blocks/lang/untranslated-callout.tsx +29 -0
  15. package/src/components/blocks/lang/untranslated-chip.tsx +53 -0
  16. package/src/components/blocks/link/link-preview.tsx +257 -0
  17. package/src/components/blocks/list/row.tsx +92 -0
  18. package/src/components/blocks/media/glb/model.tsx +244 -0
  19. package/src/components/blocks/media/glb/scene.tsx +373 -0
  20. package/src/components/blocks/media/glb/viewer.tsx +52 -0
  21. package/src/components/blocks/media/image.tsx +136 -0
  22. package/src/components/blocks/media/slideshow.tsx +178 -0
  23. package/src/components/blocks/nav/breadcrumb.tsx +51 -0
  24. package/src/components/blocks/nav/copy-menu.tsx +37 -0
  25. package/src/components/blocks/nav/link.tsx +86 -0
  26. package/src/components/blocks/nav/site-menu.tsx +78 -0
  27. package/src/components/blocks/nav/site-title.tsx +61 -0
  28. package/src/components/blocks/nav/switcher.tsx +206 -0
  29. package/src/components/blocks/page/empty-state.tsx +32 -0
  30. package/src/components/blocks/page/heading.tsx +54 -0
  31. package/src/components/blocks/page/pager.tsx +220 -0
  32. package/src/components/blocks/readability/fields.tsx +250 -0
  33. package/src/components/blocks/readability/menu.tsx +247 -0
  34. package/src/components/blocks/readability/track.tsx +165 -0
  35. package/src/components/blocks/search/command.tsx +80 -0
  36. package/src/components/blocks/search/dialog.tsx +724 -0
  37. package/src/components/blocks/search/provider.tsx +118 -0
  38. package/src/components/blocks/sidebar.tsx +406 -0
  39. package/src/components/blocks/sprite.tsx +231 -0
  40. package/src/components/blocks/text/format-text.tsx +110 -0
  41. package/src/components/blocks/theme/toggle.tsx +75 -0
  42. package/src/components/blocks/tooltip/hint.tsx +28 -0
  43. package/src/components/blocks/tooltip/lazy.tsx +41 -0
  44. package/src/components/blocks/viewer/controls.tsx +284 -0
  45. package/src/components/blocks/viewer/float-bar.tsx +51 -0
  46. package/src/components/layouts/layout.tsx +94 -0
  47. package/src/components/layouts/left-sidebar/content.tsx +63 -0
  48. package/src/components/layouts/left-sidebar/context.tsx +55 -0
  49. package/src/components/layouts/left-sidebar/expand.tsx +23 -0
  50. package/src/components/layouts/left-sidebar/footer.tsx +111 -0
  51. package/src/components/layouts/left-sidebar/index.ts +29 -0
  52. package/src/components/layouts/left-sidebar/items.tsx +103 -0
  53. package/src/components/layouts/left-sidebar/search.tsx +75 -0
  54. package/src/components/layouts/left-sidebar/sidebar.tsx +189 -0
  55. package/src/components/layouts/left-sidebar/tree.tsx +157 -0
  56. package/src/components/layouts/right-sidebar.tsx +96 -0
  57. package/src/components/layouts/tree-context.tsx +42 -0
  58. package/src/components/mdx/accordion.tsx +185 -0
  59. package/src/components/mdx/banner.tsx +157 -0
  60. package/src/components/mdx/callout.tsx +97 -0
  61. package/src/components/mdx/card.tsx +56 -0
  62. package/src/components/mdx/codeblock.tsx +250 -0
  63. package/src/components/mdx/collapsible.tsx +33 -0
  64. package/src/components/mdx/files.tsx +63 -0
  65. package/src/components/mdx/glow-header.tsx +54 -0
  66. package/src/components/mdx/gradient-text.tsx +28 -0
  67. package/src/components/mdx/icon.tsx +35 -0
  68. package/src/components/mdx/image-zoom.tsx +315 -0
  69. package/src/components/mdx/math.tsx +30 -0
  70. package/src/components/mdx/mermaid.tsx +23 -0
  71. package/src/components/mdx/multiblock.tsx +19 -0
  72. package/src/components/mdx/preview.tsx +101 -0
  73. package/src/components/mdx/steps.tsx +10 -0
  74. package/src/components/mdx/table.tsx +14 -0
  75. package/src/components/mdx/tabs.tsx +354 -0
  76. package/src/components/mdx/type-table.tsx +171 -0
  77. package/src/components/provider.tsx +139 -0
  78. package/src/components/ui/accordion.tsx +71 -0
  79. package/src/components/ui/alert-dialog.tsx +187 -0
  80. package/src/components/ui/alert.tsx +75 -0
  81. package/src/components/ui/aspect-ratio.tsx +22 -0
  82. package/src/components/ui/attachment.tsx +207 -0
  83. package/src/components/ui/avatar.tsx +108 -0
  84. package/src/components/ui/badge.tsx +51 -0
  85. package/src/components/ui/breadcrumb.tsx +124 -0
  86. package/src/components/ui/bubble.tsx +127 -0
  87. package/src/components/ui/button-group.tsx +87 -0
  88. package/src/components/ui/button.tsx +57 -0
  89. package/src/components/ui/calendar.tsx +221 -0
  90. package/src/components/ui/card.tsx +102 -0
  91. package/src/components/ui/carousel.tsx +242 -0
  92. package/src/components/ui/chart.tsx +371 -0
  93. package/src/components/ui/checkbox.tsx +28 -0
  94. package/src/components/ui/collapsible.tsx +21 -0
  95. package/src/components/ui/combobox.tsx +297 -0
  96. package/src/components/ui/command.tsx +198 -0
  97. package/src/components/ui/context-menu.tsx +270 -0
  98. package/src/components/ui/dialog.tsx +160 -0
  99. package/src/components/ui/direction.tsx +6 -0
  100. package/src/components/ui/drawer.tsx +227 -0
  101. package/src/components/ui/dropdown-menu.tsx +269 -0
  102. package/src/components/ui/empty.tsx +103 -0
  103. package/src/components/ui/field.tsx +238 -0
  104. package/src/components/ui/hover-card.tsx +50 -0
  105. package/src/components/ui/input-group.tsx +158 -0
  106. package/src/components/ui/input-otp.tsx +86 -0
  107. package/src/components/ui/input.tsx +19 -0
  108. package/src/components/ui/item.tsx +201 -0
  109. package/src/components/ui/kbd.tsx +26 -0
  110. package/src/components/ui/label.tsx +19 -0
  111. package/src/components/ui/marker.tsx +70 -0
  112. package/src/components/ui/menubar.tsx +280 -0
  113. package/src/components/ui/message-scroller.tsx +131 -0
  114. package/src/components/ui/message.tsx +91 -0
  115. package/src/components/ui/native-select.tsx +60 -0
  116. package/src/components/ui/navigation-menu.tsx +167 -0
  117. package/src/components/ui/pagination.tsx +130 -0
  118. package/src/components/ui/popover.tsx +91 -0
  119. package/src/components/ui/progress.tsx +82 -0
  120. package/src/components/ui/questionnaire.tsx +324 -0
  121. package/src/components/ui/radio-group.tsx +37 -0
  122. package/src/components/ui/resizable.tsx +49 -0
  123. package/src/components/ui/scroll-area.tsx +54 -0
  124. package/src/components/ui/select.tsx +200 -0
  125. package/src/components/ui/separator.tsx +24 -0
  126. package/src/components/ui/sheet.tsx +138 -0
  127. package/src/components/ui/sidebar.tsx +730 -0
  128. package/src/components/ui/skeleton.tsx +13 -0
  129. package/src/components/ui/slider.tsx +51 -0
  130. package/src/components/ui/spinner.tsx +10 -0
  131. package/src/components/ui/switch.tsx +31 -0
  132. package/src/components/ui/table.tsx +115 -0
  133. package/src/components/ui/tabs.tsx +81 -0
  134. package/src/components/ui/textarea.tsx +17 -0
  135. package/src/components/ui/toast.tsx +234 -0
  136. package/src/components/ui/toggle-group.tsx +89 -0
  137. package/src/components/ui/toggle.tsx +44 -0
  138. package/src/components/ui/tooltip.tsx +65 -0
  139. package/src/hooks/canvas/use-flow-hover.ts +19 -0
  140. package/src/hooks/canvas/use-flow-viewport-persist.ts +34 -0
  141. package/src/hooks/page/use-fit-pages.ts +92 -0
  142. package/src/hooks/page/use-location-search.ts +31 -0
  143. package/src/hooks/page/use-page-preview.ts +41 -0
  144. package/src/hooks/page/use-paged-list.ts +21 -0
  145. package/src/hooks/readability/use-readability.ts +87 -0
  146. package/src/hooks/search/use-dialog.ts +42 -0
  147. package/src/hooks/search/use-highlight-query.ts +58 -0
  148. package/src/hooks/search/use-provider.ts +26 -0
  149. package/src/hooks/use-async-value.ts +60 -0
  150. package/src/hooks/use-close-on-scroll.ts +24 -0
  151. package/src/hooks/use-copy-button.ts +47 -0
  152. package/src/hooks/use-cycle-index.ts +76 -0
  153. package/src/hooks/use-document-title.ts +10 -0
  154. package/src/hooks/use-element-size.ts +36 -0
  155. package/src/hooks/use-external-meta.ts +133 -0
  156. package/src/hooks/use-hot-key.ts +34 -0
  157. package/src/hooks/use-hover-follow.ts +55 -0
  158. package/src/hooks/use-in-view.ts +31 -0
  159. package/src/hooks/use-lang.ts +9 -0
  160. package/src/hooks/use-last-value.ts +16 -0
  161. package/src/hooks/use-layout.ts +19 -0
  162. package/src/hooks/use-marquee.ts +52 -0
  163. package/src/hooks/use-media-query.ts +15 -0
  164. package/src/hooks/use-mobile.ts +20 -0
  165. package/src/hooks/use-modifier-keys.ts +32 -0
  166. package/src/hooks/use-on-change.ts +36 -0
  167. package/src/hooks/use-pane-keys.ts +29 -0
  168. package/src/hooks/use-sidebar.ts +64 -0
  169. package/src/hooks/use-swipe.ts +27 -0
  170. package/src/hooks/use-tree.ts +100 -0
  171. package/src/hooks/viewer/use-in-float-bar.ts +7 -0
  172. package/src/hooks/viewer/use-viewer-prefs.ts +78 -0
  173. package/src/lib/canvas/edge.ts +202 -0
  174. package/src/lib/canvas/geometry.ts +54 -0
  175. package/src/lib/canvas/state.ts +48 -0
  176. package/src/lib/canvas/viewport.ts +38 -0
  177. package/src/lib/canvas/xyflow.ts +2 -0
  178. package/src/lib/config.ts +106 -0
  179. package/src/lib/mdx/callout.ts +21 -0
  180. package/src/lib/mdx/gradient.ts +16 -0
  181. package/src/lib/mdx/mermaid.ts +15 -0
  182. package/src/lib/media/crop-opaque.ts +77 -0
  183. package/src/lib/media/nearby-indexes.ts +7 -0
  184. package/src/lib/media/trim-transparent.ts +118 -0
  185. package/src/lib/readability/config.ts +11 -0
  186. package/src/lib/readability/spotlight.ts +187 -0
  187. package/src/lib/readability/store.ts +147 -0
  188. package/src/lib/tree.ts +90 -0
  189. package/src/lib/utils/cn.ts +2 -0
  190. package/src/lib/utils/format.ts +22 -0
  191. package/src/lib/utils/highlight-style.ts +10 -0
  192. package/src/lib/utils/history.ts +10 -0
  193. package/src/lib/utils/merge-refs.ts +14 -0
  194. package/src/lib/utils/nav-active.ts +59 -0
  195. package/src/lib/utils/stored-value.ts +137 -0
  196. package/src/lib/utils/urls.ts +18 -0
  197. package/src/locales/en_us.json +110 -0
  198. package/src/locales/pl_pl.json +33 -0
  199. package/src/locales/ru_ru.json +110 -0
  200. package/src/locales/uk_ua.json +33 -0
  201. package/src/locales/zh_cn.json +15 -0
  202. package/src/locales/zh_tw.json +15 -0
  203. package/src/locales.ts +6 -0
  204. package/src/types/canvas.ts +54 -0
  205. package/src/types/glb.ts +26 -0
  206. package/src/types/mdx-directives.ts +106 -0
  207. package/src/types/nav.ts +96 -0
  208. package/src/types/preview.ts +15 -0
  209. package/src/types/readability.ts +29 -0
  210. package/src/types/search.ts +57 -0
  211. package/src/types/tree.ts +19 -0
  212. package/src/types/viewer.ts +54 -0
  213. package/styles/content/base.css +198 -0
  214. package/styles/content/default-colors.css +51 -0
  215. package/styles/content/shiki.css +107 -0
  216. package/styles/content/typography.mjs +3637 -0
  217. package/styles/content.css +5 -0
  218. package/styles/image-zoom.css +83 -0
  219. package/styles/layout.css +28 -0
  220. package/styles/left-sidebar.css +140 -0
  221. package/styles/readability.css +50 -0
  222. package/styles/right-sidebar.css +6 -0
  223. package/styles/workspace.css +26 -0
  224. package/styles.css +430 -0
  225. package/README.md +0 -3
@@ -0,0 +1,724 @@
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/use-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('Search', { note: '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('Search', { note: '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('Close Search', { note: '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('Search', { note: '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]',
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('Search', { note: 'ui.search_dialog' }) : undefined}
459
+ className={cn('w-full flex flex-col overflow-y-auto max-h-[460px] p-1 scrollbar-thin', !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
+ badge,
534
+ ...props
535
+ }: ComponentProps<'div'> & {
536
+
537
+ table?: SearchResultTable;
538
+
539
+ renderMarkdown?: (v: string) => ReactNode;
540
+ badge?: ReactNode;
541
+ item: SearchItemType;
542
+ }) {
543
+ const { query, getActive, setActive, subscribeActive } = useSearch();
544
+ const isActive = () => getActive() === item.id;
545
+ const active = useSyncExternalStore(subscribeActive, isActive, isActive);
546
+ const highlightRef = useHighlightQuery(query);
547
+ const content = useMemo(() => {
548
+ if (item.type === 'action') return item.node;
549
+ if (renderMarkdown && typeof item.content === 'string') return renderMarkdown(item.content);
550
+ if (!('hastContent' in item)) return item.content;
551
+ if (!table) return toJsxRuntime(item.hastContent, renderOptions);
552
+ return toJsxRuntime(item.hastContent, table.header ? rowRenderOptions : tableRenderOptions);
553
+ }, [item, renderMarkdown, table]);
554
+
555
+ if (item.type === 'action') {
556
+ children ??= content;
557
+ } else if (table) {
558
+ children ??= (
559
+ <div
560
+ ref={highlightRef}
561
+ className={cn(
562
+ 'contents',
563
+
564
+ !table.header &&
565
+ '*:first:font-medium *:first:text-primary *:nth-2:text-xs *:not-first:text-muted-foreground',
566
+ )}
567
+ >
568
+ {content}
569
+ </div>
570
+ );
571
+ } else {
572
+ children ??= (
573
+ <>
574
+ {badge != null && (
575
+ <span className="absolute end-2 top-1.5 rounded-md border border-border bg-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-foreground/80">
576
+ {badge}
577
+ </span>
578
+ )}
579
+ <div
580
+ className={cn(
581
+ 'inline-flex items-center text-muted-foreground text-xs empty:hidden',
582
+ badge != null && 'pe-14',
583
+ )}
584
+ >
585
+ {item.breadcrumbs?.map((item, i) => (
586
+ <Fragment key={i}>
587
+ {i > 0 && <ChevronRight className="size-4 rtl:rotate-180" />}
588
+ {item}
589
+ </Fragment>
590
+ ))}
591
+ </div>
592
+
593
+ {item.type !== 'page' && (
594
+ <div role="none" className="absolute inset-s-3 inset-y-0 w-px bg-border" />
595
+ )}
596
+ {item.type === 'heading' && (
597
+ <Hash className="absolute inset-s-6 top-2.5 size-4 text-muted-foreground" />
598
+ )}
599
+ <div
600
+ ref={highlightRef}
601
+ className={cn(
602
+ 'prose prose-sm min-w-0',
603
+ item.type === 'text' && 'ps-4',
604
+ item.type === 'heading' && 'ps-8',
605
+ item.type === 'page' || item.type === 'heading'
606
+ ? 'font-medium text-inherit'
607
+ : 'text-popover-foreground/80',
608
+ )}
609
+ >
610
+ {content}
611
+ </div>
612
+ </>
613
+ );
614
+ }
615
+
616
+ return (
617
+ <div
618
+ id={`search-option-${item.id}`}
619
+ role="option"
620
+ ref={useCallback(
621
+ (element: HTMLDivElement | null) => {
622
+ if (active && element) {
623
+ scrollIntoView(element, {
624
+ scrollMode: 'if-needed',
625
+ block: 'nearest',
626
+ boundary: element.closest('[role="listbox"]'),
627
+ });
628
+ }
629
+ },
630
+ [active],
631
+ )}
632
+ aria-selected={active}
633
+ className={cn(
634
+ 'cursor-default',
635
+ table
636
+ ? 'col-span-full grid grid-cols-subgrid items-baseline px-3 py-1.5 not-first:border-t'
637
+ : 'relative shrink-0 px-2.5 py-2 text-sm overflow-hidden rounded-lg',
638
+ active && 'bg-accent text-accent-foreground',
639
+ className,
640
+ )}
641
+ onPointerMove={() => setActive(item.id)}
642
+ {...props}
643
+ >
644
+ {children}
645
+ </div>
646
+ );
647
+ }
648
+
649
+ export function SearchDialogIcon(props: ComponentProps<'svg'>) {
650
+ const { isLoading } = useSearch();
651
+
652
+ return (
653
+ <SearchIcon
654
+ {...props}
655
+ className={cn(
656
+ 'size-5 text-muted-foreground',
657
+ isLoading && 'animate-pulse duration-400',
658
+ props.className,
659
+ )}
660
+ />
661
+ );
662
+ }
663
+
664
+ export interface SearchTagsListProps extends ComponentProps<'div'> {
665
+ tag?: string;
666
+ onTagChange: (tag: string | undefined) => void;
667
+ allowClear?: boolean;
668
+ }
669
+
670
+ const itemVariants = cva(
671
+ 'rounded-md border px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors',
672
+ {
673
+ variants: {
674
+ active: {
675
+ true: 'bg-accent text-accent-foreground',
676
+ },
677
+ },
678
+ },
679
+ );
680
+ export function SearchTagsList({ tag, onTagChange, allowClear = false, ...props }: SearchTagsListProps) {
681
+ const onTagChangeCallback = useRef(onTagChange);
682
+ onTagChangeCallback.current = onTagChange;
683
+ return (
684
+ <div {...props} className={cn('flex items-center gap-1 flex-wrap', props.className)}>
685
+ <SearchTagsListContext
686
+ value={useMemo(
687
+ () => ({
688
+ value: tag,
689
+ onValueChange: (v) => onTagChangeCallback.current(v),
690
+ allowClear,
691
+ }),
692
+ [allowClear, tag],
693
+ )}
694
+ >
695
+ {props.children}
696
+ </SearchTagsListContext>
697
+ </div>
698
+ );
699
+ }
700
+
701
+ export function SearchTagsListItem({
702
+ value,
703
+ className,
704
+ ...props
705
+ }: ComponentProps<'button'> & {
706
+ value: string;
707
+ }) {
708
+ const { onValueChange, value: selectedValue, allowClear } = useSearchTagsList();
709
+ const selected = value === selectedValue;
710
+
711
+ return (
712
+ <button
713
+ type="button"
714
+ data-active={selected}
715
+ className={cn(itemVariants({ active: selected, className }))}
716
+ onClick={() => onValueChange(selected && allowClear ? undefined : value)}
717
+ tabIndex={-1}
718
+ {...props}
719
+ >
720
+ {props.children}
721
+ </button>
722
+ );
723
+ }
724
+