@mostrom/app-shell 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.
- package/.claude/ralph-loop.local.md +9 -0
- package/README.md +172 -0
- package/bin/init.js +269 -0
- package/bun.lock +401 -0
- package/components.json +28 -0
- package/package.json +74 -0
- package/scripts/publish-npm.sh +202 -0
- package/src/AppShell.tsx +847 -0
- package/src/components/PageHeader.tsx +160 -0
- package/src/components/data-table/README.md +447 -0
- package/src/components/data-table/data-table-preferences.tsx +184 -0
- package/src/components/data-table/data-table-toolbar.tsx +118 -0
- package/src/components/data-table/data-table.tsx +37 -0
- package/src/components/data-table/index.ts +32 -0
- package/src/components/global-header/AllServicesButton.tsx +127 -0
- package/src/components/global-header/CategoriesButton.tsx +120 -0
- package/src/components/global-header/GlobalHeader.tsx +59 -0
- package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
- package/src/components/global-header/HeaderUtilities.tsx +243 -0
- package/src/components/global-header/ServicesMenu.tsx +246 -0
- package/src/components/layout/AppBreadcrumb.tsx +70 -0
- package/src/components/layout/AppFlashbar.tsx +95 -0
- package/src/components/layout/AppLayout.tsx +271 -0
- package/src/components/layout/AppNavigation.tsx +313 -0
- package/src/components/layout/AppSidebar.tsx +229 -0
- package/src/components/patterns/index.ts +14 -0
- package/src/components/patterns/p-alert-5.tsx +19 -0
- package/src/components/patterns/p-autocomplete-5.tsx +89 -0
- package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
- package/src/components/patterns/p-button-42.tsx +37 -0
- package/src/components/patterns/p-button-51.tsx +14 -0
- package/src/components/patterns/p-button-6.tsx +5 -0
- package/src/components/patterns/p-calendar-1.tsx +18 -0
- package/src/components/patterns/p-card-1.tsx +33 -0
- package/src/components/patterns/p-card-2.tsx +26 -0
- package/src/components/patterns/p-card-5.tsx +31 -0
- package/src/components/patterns/p-collapsible-7.tsx +121 -0
- package/src/components/patterns/p-command-6.tsx +113 -0
- package/src/components/patterns/p-dialog-1.tsx +56 -0
- package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
- package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
- package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
- package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
- package/src/components/patterns/p-empty-2.tsx +34 -0
- package/src/components/patterns/p-file-upload-1.tsx +72 -0
- package/src/components/patterns/p-filters-1.tsx +666 -0
- package/src/components/patterns/p-frame-2.tsx +26 -0
- package/src/components/patterns/p-tabs-2.tsx +129 -0
- package/src/components/reui/alert.tsx +92 -0
- package/src/components/reui/autocomplete.tsx +343 -0
- package/src/components/reui/badge.tsx +87 -0
- package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
- package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
- package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
- package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
- package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
- package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
- package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
- package/src/components/reui/data-grid/data-grid.tsx +209 -0
- package/src/components/reui/date-selector.tsx +1330 -0
- package/src/components/reui/filters.tsx +1869 -0
- package/src/components/reui/frame.tsx +134 -0
- package/src/components/reui/index.ts +17 -0
- package/src/components/reui/timeline.tsx +219 -0
- package/src/components/search/Autocomplete.tsx +183 -0
- package/src/components/search/AutocompleteClient.tsx +293 -0
- package/src/components/search/GlobalSearch.tsx +187 -0
- package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
- package/src/components/section-drawer/index.ts +19 -0
- package/src/components/section-drawer/section-drawer.css +665 -0
- package/src/components/section-drawer/section-drawer.tsx +467 -0
- package/src/components/sectioned-list-board/README.md +78 -0
- package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
- package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
- package/src/components/sectioned-list-board/index.ts +19 -0
- package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
- package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
- package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
- package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
- package/src/components/sectioned-list-board/types.ts +216 -0
- package/src/components/sectioned-list-table/README.md +80 -0
- package/src/components/sectioned-list-table/index.ts +14 -0
- package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
- package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
- package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
- package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
- package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
- package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
- package/src/components/sectioned-list-table/types.ts +120 -0
- package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
- package/src/components/ui/actions-dropdown.tsx +109 -0
- package/src/components/ui/assignee-selector.tsx +209 -0
- package/src/components/ui/avatar.tsx +107 -0
- package/src/components/ui/breadcrumb.tsx +109 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +376 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/command.tsx +182 -0
- package/src/components/ui/context-menu.tsx +250 -0
- package/src/components/ui/create-button-group.tsx +128 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +133 -0
- package/src/components/ui/dropdown-menu.tsx +255 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +248 -0
- package/src/components/ui/form.tsx +165 -0
- package/src/components/ui/index.ts +37 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/page-header.tsx +80 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/scroll-area.tsx +56 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +141 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +38 -0
- package/src/components/ui/switch.tsx +33 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-copy-to-clipboard.ts +37 -0
- package/src/hooks/use-file-upload.ts +415 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +1859 -0
- package/src/urls.ts +83 -0
- package/src/vite.d.ts +22 -0
- package/src/vite.js +241 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Client-only implementation - this file imports @base-ui/react directly
|
|
2
|
+
// It should only be dynamically imported on the client
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete";
|
|
5
|
+
|
|
6
|
+
// Autocomplete Root
|
|
7
|
+
export const AutocompleteRoot = AutocompletePrimitive.Root;
|
|
8
|
+
|
|
9
|
+
// Autocomplete Value
|
|
10
|
+
export function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
|
11
|
+
return (
|
|
12
|
+
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Autocomplete Input
|
|
17
|
+
interface AutocompleteInputProps extends Omit<AutocompletePrimitive.Input.Props, "size"> {
|
|
18
|
+
showClear?: boolean;
|
|
19
|
+
showTrigger?: boolean;
|
|
20
|
+
icon?: React.ReactNode;
|
|
21
|
+
loading?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AutocompleteInput({
|
|
25
|
+
className,
|
|
26
|
+
showClear = false,
|
|
27
|
+
showTrigger = false,
|
|
28
|
+
icon,
|
|
29
|
+
loading = false,
|
|
30
|
+
...props
|
|
31
|
+
}: AutocompleteInputProps) {
|
|
32
|
+
return (
|
|
33
|
+
// suppressHydrationWarning: browser extensions (Dashlane, 1Password, etc.) inject attributes before hydration
|
|
34
|
+
<div className={`app-shell-search-input-container ${className ?? ""}`} suppressHydrationWarning>
|
|
35
|
+
{icon && (
|
|
36
|
+
<span className="app-shell-search-icon">
|
|
37
|
+
{loading ? <LoadingSpinner /> : icon}
|
|
38
|
+
</span>
|
|
39
|
+
)}
|
|
40
|
+
<AutocompletePrimitive.Input
|
|
41
|
+
data-slot="autocomplete-input"
|
|
42
|
+
className="app-shell-search-input"
|
|
43
|
+
{...props}
|
|
44
|
+
/>
|
|
45
|
+
{showTrigger && <AutocompleteTrigger />}
|
|
46
|
+
{showClear && <AutocompleteClear />}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Loading Spinner
|
|
52
|
+
const LoadingSpinner = () => (
|
|
53
|
+
<svg
|
|
54
|
+
width="16"
|
|
55
|
+
height="16"
|
|
56
|
+
viewBox="0 0 16 16"
|
|
57
|
+
fill="none"
|
|
58
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
59
|
+
className="app-shell-search-spinner"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
>
|
|
62
|
+
<circle
|
|
63
|
+
cx="8"
|
|
64
|
+
cy="8"
|
|
65
|
+
r="6"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth="2"
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeDasharray="32"
|
|
70
|
+
strokeDashoffset="12"
|
|
71
|
+
/>
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Portal
|
|
76
|
+
export function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
|
77
|
+
return (
|
|
78
|
+
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Backdrop
|
|
83
|
+
export function AutocompleteBackdrop({
|
|
84
|
+
...props
|
|
85
|
+
}: AutocompletePrimitive.Backdrop.Props) {
|
|
86
|
+
return (
|
|
87
|
+
<AutocompletePrimitive.Backdrop
|
|
88
|
+
data-slot="autocomplete-backdrop"
|
|
89
|
+
{...props}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Positioner
|
|
95
|
+
export function AutocompletePositioner({
|
|
96
|
+
className,
|
|
97
|
+
...props
|
|
98
|
+
}: AutocompletePrimitive.Positioner.Props) {
|
|
99
|
+
return (
|
|
100
|
+
<AutocompletePrimitive.Positioner
|
|
101
|
+
data-slot="autocomplete-positioner"
|
|
102
|
+
className={`app-shell-search-positioner ${className ?? ""}`}
|
|
103
|
+
{...props}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// List
|
|
109
|
+
export function AutocompleteList({
|
|
110
|
+
className,
|
|
111
|
+
...props
|
|
112
|
+
}: AutocompletePrimitive.List.Props) {
|
|
113
|
+
return (
|
|
114
|
+
<AutocompletePrimitive.List
|
|
115
|
+
data-slot="autocomplete-list"
|
|
116
|
+
className={`app-shell-search-options ${className ?? ""}`}
|
|
117
|
+
{...props}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Item
|
|
123
|
+
export function AutocompleteItem({
|
|
124
|
+
className,
|
|
125
|
+
...props
|
|
126
|
+
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
|
127
|
+
return (
|
|
128
|
+
<AutocompletePrimitive.Item
|
|
129
|
+
data-slot="autocomplete-item"
|
|
130
|
+
className={`app-shell-search-option ${className ?? ""}`}
|
|
131
|
+
{...props}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Content (combines Portal, Positioner, and Popup)
|
|
137
|
+
export interface AutocompleteContentProps extends React.ComponentProps<
|
|
138
|
+
typeof AutocompletePrimitive.Popup
|
|
139
|
+
> {
|
|
140
|
+
align?: AutocompletePrimitive.Positioner.Props["align"];
|
|
141
|
+
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"];
|
|
142
|
+
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"];
|
|
143
|
+
side?: AutocompletePrimitive.Positioner.Props["side"];
|
|
144
|
+
anchor?: AutocompletePrimitive.Positioner.Props["anchor"];
|
|
145
|
+
showBackdrop?: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function AutocompleteContent({
|
|
149
|
+
className,
|
|
150
|
+
children,
|
|
151
|
+
showBackdrop = false,
|
|
152
|
+
align = "start",
|
|
153
|
+
sideOffset = 4,
|
|
154
|
+
alignOffset = 0,
|
|
155
|
+
side = "bottom",
|
|
156
|
+
anchor,
|
|
157
|
+
...props
|
|
158
|
+
}: AutocompleteContentProps) {
|
|
159
|
+
const useServicesLayout = Boolean(
|
|
160
|
+
typeof className === "string" &&
|
|
161
|
+
className.includes("app-shell-services-dropdown")
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<AutocompletePortal>
|
|
166
|
+
{showBackdrop && <AutocompleteBackdrop />}
|
|
167
|
+
<AutocompletePositioner
|
|
168
|
+
className={useServicesLayout ? "app-shell-search-positioner-services" : undefined}
|
|
169
|
+
align={align}
|
|
170
|
+
sideOffset={sideOffset}
|
|
171
|
+
alignOffset={alignOffset}
|
|
172
|
+
side={side}
|
|
173
|
+
anchor={anchor}
|
|
174
|
+
>
|
|
175
|
+
<AutocompletePrimitive.Popup
|
|
176
|
+
data-slot="autocomplete-popup"
|
|
177
|
+
className={`app-shell-search-dropdown ${className ?? ""}`}
|
|
178
|
+
{...props}
|
|
179
|
+
>
|
|
180
|
+
{children}
|
|
181
|
+
</AutocompletePrimitive.Popup>
|
|
182
|
+
</AutocompletePositioner>
|
|
183
|
+
</AutocompletePortal>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Group
|
|
188
|
+
export function AutocompleteGroup({
|
|
189
|
+
...props
|
|
190
|
+
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
|
191
|
+
return (
|
|
192
|
+
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Group Label
|
|
197
|
+
export function AutocompleteGroupLabel({
|
|
198
|
+
className,
|
|
199
|
+
...props
|
|
200
|
+
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
|
201
|
+
return (
|
|
202
|
+
<AutocompletePrimitive.GroupLabel
|
|
203
|
+
data-slot="autocomplete-group-label"
|
|
204
|
+
className={`app-shell-search-group-label ${className ?? ""}`}
|
|
205
|
+
{...props}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Empty state
|
|
211
|
+
export function AutocompleteEmpty({
|
|
212
|
+
className,
|
|
213
|
+
...props
|
|
214
|
+
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
|
215
|
+
return (
|
|
216
|
+
<AutocompletePrimitive.Empty
|
|
217
|
+
data-slot="autocomplete-empty"
|
|
218
|
+
className={`app-shell-search-empty ${className ?? ""}`}
|
|
219
|
+
{...props}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Clear button
|
|
225
|
+
export function AutocompleteClear({
|
|
226
|
+
className,
|
|
227
|
+
...props
|
|
228
|
+
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
|
229
|
+
return (
|
|
230
|
+
<AutocompletePrimitive.Clear
|
|
231
|
+
data-slot="autocomplete-clear"
|
|
232
|
+
className={`app-shell-search-clear ${className ?? ""}`}
|
|
233
|
+
{...props}
|
|
234
|
+
>
|
|
235
|
+
<ClearIcon />
|
|
236
|
+
</AutocompletePrimitive.Clear>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Trigger button
|
|
241
|
+
export function AutocompleteTrigger({
|
|
242
|
+
className,
|
|
243
|
+
...props
|
|
244
|
+
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
|
245
|
+
return (
|
|
246
|
+
<AutocompletePrimitive.Trigger
|
|
247
|
+
data-slot="autocomplete-trigger"
|
|
248
|
+
className={`app-shell-search-trigger ${className ?? ""}`}
|
|
249
|
+
{...props}
|
|
250
|
+
>
|
|
251
|
+
<ChevronIcon />
|
|
252
|
+
</AutocompletePrimitive.Trigger>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Icons
|
|
257
|
+
const ClearIcon = () => (
|
|
258
|
+
<svg
|
|
259
|
+
width="16"
|
|
260
|
+
height="16"
|
|
261
|
+
viewBox="0 0 16 16"
|
|
262
|
+
fill="none"
|
|
263
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
264
|
+
aria-hidden="true"
|
|
265
|
+
>
|
|
266
|
+
<path
|
|
267
|
+
d="M12 4L4 12M4 4L12 12"
|
|
268
|
+
stroke="currentColor"
|
|
269
|
+
strokeWidth="1.5"
|
|
270
|
+
strokeLinecap="round"
|
|
271
|
+
strokeLinejoin="round"
|
|
272
|
+
/>
|
|
273
|
+
</svg>
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const ChevronIcon = () => (
|
|
277
|
+
<svg
|
|
278
|
+
width="16"
|
|
279
|
+
height="16"
|
|
280
|
+
viewBox="0 0 16 16"
|
|
281
|
+
fill="none"
|
|
282
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
283
|
+
aria-hidden="true"
|
|
284
|
+
>
|
|
285
|
+
<path
|
|
286
|
+
d="M4 6L8 10L12 6"
|
|
287
|
+
stroke="currentColor"
|
|
288
|
+
strokeWidth="1.5"
|
|
289
|
+
strokeLinecap="round"
|
|
290
|
+
strokeLinejoin="round"
|
|
291
|
+
/>
|
|
292
|
+
</svg>
|
|
293
|
+
);
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Autocomplete,
|
|
4
|
+
AutocompleteContent,
|
|
5
|
+
AutocompleteEmpty,
|
|
6
|
+
AutocompleteInput,
|
|
7
|
+
AutocompleteItem,
|
|
8
|
+
AutocompleteList,
|
|
9
|
+
} from "./Autocomplete";
|
|
10
|
+
|
|
11
|
+
// Search icon
|
|
12
|
+
const SearchIcon = () => (
|
|
13
|
+
<svg
|
|
14
|
+
width="16"
|
|
15
|
+
height="16"
|
|
16
|
+
viewBox="0 0 16 16"
|
|
17
|
+
fill="none"
|
|
18
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
>
|
|
21
|
+
<path
|
|
22
|
+
d="M11.5 11.5L14.5 14.5M7.5 13C4.18629 13 1.5 10.3137 1.5 7C1.5 3.68629 4.18629 1 7.5 1C10.8137 1 13.5 3.68629 13.5 7C13.5 10.3137 10.8137 13 7.5 13Z"
|
|
23
|
+
stroke="currentColor"
|
|
24
|
+
strokeWidth="1.5"
|
|
25
|
+
strokeLinecap="round"
|
|
26
|
+
strokeLinejoin="round"
|
|
27
|
+
/>
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export interface SearchOption {
|
|
32
|
+
value: string;
|
|
33
|
+
label?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
tags?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GlobalSearchProps {
|
|
39
|
+
/** Current search input value */
|
|
40
|
+
value: string;
|
|
41
|
+
/** Callback when input value changes */
|
|
42
|
+
onChange: (value: string) => void;
|
|
43
|
+
/** Callback when an option is selected */
|
|
44
|
+
onSelect: (option: SearchOption) => void;
|
|
45
|
+
/** Placeholder text for the search input */
|
|
46
|
+
placeholder?: string;
|
|
47
|
+
/** List of search suggestions/options */
|
|
48
|
+
options: SearchOption[];
|
|
49
|
+
/** Content to show when there are no results */
|
|
50
|
+
empty?: React.ReactNode;
|
|
51
|
+
/** Loading state */
|
|
52
|
+
loading?: boolean;
|
|
53
|
+
/** Aria label for accessibility */
|
|
54
|
+
ariaLabel?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function GlobalSearch({
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
onSelect,
|
|
61
|
+
placeholder = "Search",
|
|
62
|
+
options,
|
|
63
|
+
empty = "No results found",
|
|
64
|
+
loading = false,
|
|
65
|
+
ariaLabel = "Search",
|
|
66
|
+
}: GlobalSearchProps) {
|
|
67
|
+
// Track last typed value to detect selection
|
|
68
|
+
const lastTypedValue = useRef(value);
|
|
69
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
|
|
71
|
+
// Options are already filtered by the parent component (AppShell uses searchServices)
|
|
72
|
+
// No need to re-filter here - just use the options as-is
|
|
73
|
+
const filteredOptions = options;
|
|
74
|
+
const hasQuery = value.trim().length > 0;
|
|
75
|
+
const groupedOptions = useMemo(() => {
|
|
76
|
+
const groups = new Map<string, SearchOption[]>();
|
|
77
|
+
|
|
78
|
+
filteredOptions.forEach((option) => {
|
|
79
|
+
const groupName = option.tags?.[0] ?? "Services";
|
|
80
|
+
const existing = groups.get(groupName) ?? [];
|
|
81
|
+
existing.push(option);
|
|
82
|
+
groups.set(groupName, existing);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return Array.from(groups.entries()).map(([title, items]) => ({ title, items }));
|
|
86
|
+
}, [filteredOptions]);
|
|
87
|
+
|
|
88
|
+
// Convert option to display string
|
|
89
|
+
const itemToString = useCallback((item: unknown): string => {
|
|
90
|
+
const option = item as SearchOption | null;
|
|
91
|
+
return option?.label ?? option?.value ?? "";
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
// Handle value changes - detect if an option was selected
|
|
95
|
+
const handleValueChange = useCallback(
|
|
96
|
+
(newValue: string) => {
|
|
97
|
+
// Check if the new value matches an option exactly (indicating selection)
|
|
98
|
+
const matchedOption = options.find(
|
|
99
|
+
(option) => (option.label ?? option.value) === newValue
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (matchedOption && newValue !== lastTypedValue.current) {
|
|
103
|
+
// Option was selected
|
|
104
|
+
onSelect(matchedOption);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
lastTypedValue.current = newValue;
|
|
108
|
+
onChange(newValue);
|
|
109
|
+
},
|
|
110
|
+
[options, onChange, onSelect]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
// suppressHydrationWarning: browser extensions (Dashlane, 1Password, etc.) inject attributes before hydration
|
|
115
|
+
<div
|
|
116
|
+
ref={containerRef}
|
|
117
|
+
className="app-shell-search-input-wrapper"
|
|
118
|
+
suppressHydrationWarning
|
|
119
|
+
>
|
|
120
|
+
<Autocomplete
|
|
121
|
+
value={value}
|
|
122
|
+
onValueChange={handleValueChange}
|
|
123
|
+
items={filteredOptions}
|
|
124
|
+
itemToStringValue={itemToString}
|
|
125
|
+
mode="none"
|
|
126
|
+
>
|
|
127
|
+
<AutocompleteInput
|
|
128
|
+
placeholder={placeholder}
|
|
129
|
+
aria-label={ariaLabel}
|
|
130
|
+
icon={<SearchIcon />}
|
|
131
|
+
loading={loading}
|
|
132
|
+
showClear
|
|
133
|
+
/>
|
|
134
|
+
<AutocompleteContent className="app-shell-services-dropdown">
|
|
135
|
+
<AutocompleteEmpty>{hasQuery ? empty : ""}</AutocompleteEmpty>
|
|
136
|
+
{filteredOptions.length > 0 && (
|
|
137
|
+
<div className="app-shell-services-menu" role="menu">
|
|
138
|
+
<div className="app-shell-services-categories" role="presentation">
|
|
139
|
+
<div className="app-shell-services-categories-title">Categories</div>
|
|
140
|
+
<ul>
|
|
141
|
+
{groupedOptions.map((group) => (
|
|
142
|
+
<li key={group.title}>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="app-shell-services-category"
|
|
146
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
147
|
+
aria-label={group.title}
|
|
148
|
+
>
|
|
149
|
+
{group.title}
|
|
150
|
+
</button>
|
|
151
|
+
</li>
|
|
152
|
+
))}
|
|
153
|
+
</ul>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="app-shell-services-divider" aria-hidden="true" />
|
|
156
|
+
<div className="app-shell-services-list" role="presentation">
|
|
157
|
+
{groupedOptions.map((group) => (
|
|
158
|
+
<div className="app-shell-services-group" key={group.title}>
|
|
159
|
+
<div className="app-shell-services-group-title">{group.title}</div>
|
|
160
|
+
<AutocompleteList>
|
|
161
|
+
{group.items.map((option) => (
|
|
162
|
+
<AutocompleteItem
|
|
163
|
+
key={option.value}
|
|
164
|
+
value={option}
|
|
165
|
+
className="app-shell-services-item"
|
|
166
|
+
>
|
|
167
|
+
<span className="app-shell-services-item-text">
|
|
168
|
+
{option.label ?? option.value}
|
|
169
|
+
</span>
|
|
170
|
+
{option.description ? (
|
|
171
|
+
<span className="app-shell-services-item-description">
|
|
172
|
+
{option.description}
|
|
173
|
+
</span>
|
|
174
|
+
) : null}
|
|
175
|
+
</AutocompleteItem>
|
|
176
|
+
))}
|
|
177
|
+
</AutocompleteList>
|
|
178
|
+
</div>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</AutocompleteContent>
|
|
184
|
+
</Autocomplete>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|