@fayz-ai/ui 0.8.1 → 0.8.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.
Files changed (57) hide show
  1. package/dist/{chunk-OTUH4K4B.js → chunk-2GDQUARM.js} +214 -4
  2. package/dist/chunk-2GDQUARM.js.map +1 -0
  3. package/dist/{chunk-W5O2X277.js → chunk-A7O7KGNL.js} +8 -4
  4. package/dist/chunk-A7O7KGNL.js.map +1 -0
  5. package/dist/{chunk-FY7OZNKU.js → chunk-BVF2KPTT.js} +7 -7
  6. package/dist/{chunk-FY7OZNKU.js.map → chunk-BVF2KPTT.js.map} +1 -1
  7. package/dist/{chunk-MZZMZYSE.js → chunk-ID2HQK2Q.js} +328 -51
  8. package/dist/chunk-ID2HQK2Q.js.map +1 -0
  9. package/dist/{chunk-ODY3GZ3Y.cjs → chunk-MI6FOB4O.cjs} +7 -7
  10. package/dist/{chunk-ODY3GZ3Y.cjs.map → chunk-MI6FOB4O.cjs.map} +1 -1
  11. package/dist/{chunk-7PTRAWHO.cjs → chunk-SVVKUMSL.cjs} +217 -6
  12. package/dist/chunk-SVVKUMSL.cjs.map +1 -0
  13. package/dist/{chunk-NWPMNPGR.cjs → chunk-WF3STNDX.cjs} +38 -34
  14. package/dist/chunk-WF3STNDX.cjs.map +1 -0
  15. package/dist/{chunk-XAOGMWLC.cjs → chunk-XIRJGVDK.cjs} +325 -48
  16. package/dist/chunk-XIRJGVDK.cjs.map +1 -0
  17. package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
  18. package/dist/dashboard/index.cjs +26 -26
  19. package/dist/dashboard/index.js +2 -2
  20. package/dist/index.cjs +125 -121
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -4
  24. package/dist/layout/AppShell.d.ts +41 -1
  25. package/dist/layout/AppShell.d.ts.map +1 -1
  26. package/dist/layout/ModulePage.d.ts +9 -6
  27. package/dist/layout/ModulePage.d.ts.map +1 -1
  28. package/dist/layout/Sidebar.d.ts +20 -1
  29. package/dist/layout/Sidebar.d.ts.map +1 -1
  30. package/dist/layout/Topbar.d.ts +13 -1
  31. package/dist/layout/Topbar.d.ts.map +1 -1
  32. package/dist/layout/index.cjs +19 -19
  33. package/dist/layout/index.js +1 -1
  34. package/dist/primitives/SearchCombobox.d.ts +38 -0
  35. package/dist/primitives/SearchCombobox.d.ts.map +1 -0
  36. package/dist/primitives/data-table.d.ts.map +1 -1
  37. package/dist/primitives/index.cjs +80 -76
  38. package/dist/primitives/index.d.ts +1 -0
  39. package/dist/primitives/index.d.ts.map +1 -1
  40. package/dist/primitives/index.js +2 -2
  41. package/package.json +3 -3
  42. package/src/dashboard/OnboardingWidget.tsx +10 -1
  43. package/src/index.ts +2 -0
  44. package/src/layout/AppShell.tsx +255 -7
  45. package/src/layout/ModulePage.tsx +32 -3
  46. package/src/layout/Sidebar.tsx +132 -35
  47. package/src/layout/Topbar.tsx +51 -4
  48. package/src/primitives/SearchCombobox.tsx +216 -0
  49. package/src/primitives/data-table.tsx +10 -1
  50. package/src/primitives/index.ts +1 -0
  51. package/src/styles.css +34 -0
  52. package/dist/chunk-7PTRAWHO.cjs.map +0 -1
  53. package/dist/chunk-MZZMZYSE.js.map +0 -1
  54. package/dist/chunk-NWPMNPGR.cjs.map +0 -1
  55. package/dist/chunk-OTUH4K4B.js.map +0 -1
  56. package/dist/chunk-W5O2X277.js.map +0 -1
  57. package/dist/chunk-XAOGMWLC.cjs.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react'
2
2
  import * as Tooltip from '@radix-ui/react-tooltip'
3
3
  import {
4
+ Crown,
4
5
  Home,
5
6
  Users,
6
7
  Settings,
@@ -82,6 +83,8 @@ import {
82
83
  Inbox,
83
84
  Star,
84
85
  TrendingUp,
86
+ GraduationCap,
87
+ Repeat,
85
88
  UserCheck,
86
89
  UserPlus,
87
90
  UserX,
@@ -89,6 +92,11 @@ import {
89
92
  Boxes,
90
93
  Clock3,
91
94
  FileCheck2,
95
+ User,
96
+ Palette,
97
+ ShieldCheck,
98
+ SlidersHorizontal,
99
+ Puzzle,
92
100
  type LucideIcon,
93
101
  } from 'lucide-react'
94
102
  import { cn } from '../utils/cn'
@@ -102,6 +110,10 @@ export interface NavigationItem {
102
110
  section: 'main' | 'secondary' | 'settings'
103
111
  badge?: string | number
104
112
  permission?: { feature: string; action: 'read' | 'create' | 'edit' | 'delete' }
113
+ /** Freemium discovery: the user's role allows this item, but their plan does
114
+ * not entitle the feature. The item stays visible with a small Crown badge
115
+ * and, on click, leads to the plan's UpgradePrompt. */
116
+ premium?: boolean
105
117
  children?: NavigationItem[]
106
118
  }
107
119
 
@@ -135,6 +147,17 @@ export interface SidebarProps {
135
147
  notificationSlot?: React.ReactNode
136
148
  /** Unread notification count for the bell indicator */
137
149
  unreadCount?: number
150
+ /** Optional headings above each nav section. Used by contextual sidebars
151
+ * (e.g. /settings swaps the rail for "Settings links" + "Plugins"); the
152
+ * normal app rail leaves them undefined and renders no headings. */
153
+ sectionLabels?: { main?: string; secondary?: string; settings?: string }
154
+ /** Identity of the current rail context. When it changes, the rail animates
155
+ * as a push: the new nav slides in while the previous one slides out.
156
+ * Leave undefined for a static rail (no swap animation). */
157
+ navKey?: string
158
+ /** Push direction for a `navKey` change. 'forward' pulls the new rail in
159
+ * from the right (drilling in), 'back' from the left (returning). */
160
+ navDirection?: 'forward' | 'back'
138
161
  }
139
162
 
140
163
  const ICON_MAP: Record<string, LucideIcon> = {
@@ -145,10 +168,21 @@ const ICON_MAP: Record<string, LucideIcon> = {
145
168
  Building2, Filter, Plus, List, ListPlus, Search, Shield, Ban, Clock, Dog, Cat, PawPrint, Heart, FolderOpen, LayoutTemplate, LeafyGreen, Apple, Egg, Wheat,
146
169
  PartyPopper, Radio, CalendarDays, CalendarCheck2, CalendarX, BadgeDollarSign, Banknote, Landmark, CircleDollarSign, Layers, Music, Eye, ListMusic, Disc3, UsersRound, Mic,
147
170
  Zap, Award, LayoutDashboard, MessageSquare, Inbox, Star, TrendingUp, UserCheck, UserPlus, UserX, Workflow, Boxes, Clock3, FileCheck2,
171
+ GraduationCap, Repeat,
172
+ User, Palette, ShieldCheck, SlidersHorizontal, Puzzle,
148
173
  }
149
174
 
150
175
  export { ICON_MAP }
151
176
 
177
+ /** Kept in sync with --saas-rail-duration in styles.css. */
178
+ const RAIL_PUSH_MS = 260
179
+
180
+ /** Layout effect on the client, plain effect during SSR (avoids the warning).
181
+ * The rail push needs the layout variant: the animation classes must be on
182
+ * the DOM before paint, or the new rail shows one frame at its final spot. */
183
+ const useIsomorphicLayoutEffect =
184
+ typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
185
+
152
186
  function getIcon(name: string): LucideIcon {
153
187
  return ICON_MAP[name] ?? Home
154
188
  }
@@ -182,6 +216,9 @@ function NavItem({
182
216
  {!collapsed && (
183
217
  <>
184
218
  <span className="flex-1 truncate text-left">{item.label}</span>
219
+ {item.premium && (
220
+ <Crown className="ml-auto h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden />
221
+ )}
185
222
  {item.badge !== undefined && (
186
223
  <span className="ml-auto rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground">
187
224
  {item.badge}
@@ -203,6 +240,9 @@ function NavItem({
203
240
  className="fayz-glass-surface z-50 rounded-md bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md"
204
241
  >
205
242
  {item.label}
243
+ {item.premium && (
244
+ <Crown className="ml-1.5 inline h-3 w-3 text-amber-500" aria-hidden />
245
+ )}
206
246
  {item.badge !== undefined && (
207
247
  <span className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-xs text-primary-foreground">
208
248
  {item.badge}
@@ -339,6 +379,9 @@ export function Sidebar({
339
379
  userMenuSlot,
340
380
  notificationSlot,
341
381
  unreadCount = 0,
382
+ sectionLabels,
383
+ navKey,
384
+ navDirection = 'forward',
342
385
  }: SidebarProps) {
343
386
  const currentPath = currentPathProp
344
387
  const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
@@ -355,6 +398,70 @@ export function Sidebar({
355
398
  onNavigate?.(route)
356
399
  }
357
400
 
401
+ const sectionHeading = (label?: string) =>
402
+ label && !collapsed ? (
403
+ <p className="px-2.5 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-muted">
404
+ {label}
405
+ </p>
406
+ ) : null
407
+
408
+ const renderItem = (item: NavigationItem) =>
409
+ item.children && item.children.length > 0 ? (
410
+ <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
411
+ ) : (
412
+ <NavItem
413
+ key={item.id}
414
+ item={item}
415
+ collapsed={collapsed}
416
+ isActive={item.route === activeRoute}
417
+ onNavigate={handleNavigate}
418
+ />
419
+ )
420
+
421
+ // The swappable part of the rail (top slot + both nav sections). Kept as one
422
+ // node so a context change can snapshot the outgoing rail and animate it out.
423
+ const navBody = (
424
+ <>
425
+ {topContent && <div className="mb-4 space-y-2">{topContent}</div>}
426
+ {mainItems.length > 0 && (
427
+ <div className="space-y-1">
428
+ {sectionHeading(sectionLabels?.main)}
429
+ {mainItems.map(renderItem)}
430
+ </div>
431
+ )}
432
+ {secondaryItems.length > 0 && (
433
+ <div className="mt-6 space-y-1">
434
+ {sectionHeading(sectionLabels?.secondary)}
435
+ {secondaryItems.map(renderItem)}
436
+ </div>
437
+ )}
438
+ </>
439
+ )
440
+
441
+ // Rail push: when `navKey` changes, hold the previous rail on screen for one
442
+ // animation cycle. `lastBodyRef` is refreshed after every render, so the
443
+ // snapshot is the rail exactly as it looked before the swap (correct active
444
+ // item included) rather than a stale one.
445
+ const [outgoing, setOutgoing] = React.useState<{ body: React.ReactNode; direction: 'forward' | 'back' } | null>(null)
446
+ const lastBodyRef = React.useRef<React.ReactNode>(null)
447
+ const lastKeyRef = React.useRef(navKey)
448
+ const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
449
+
450
+ useIsomorphicLayoutEffect(() => {
451
+ if (lastKeyRef.current !== navKey) {
452
+ const previousBody = lastBodyRef.current
453
+ lastKeyRef.current = navKey
454
+ if (previousBody) {
455
+ setOutgoing({ body: previousBody, direction: navDirection })
456
+ if (timerRef.current) clearTimeout(timerRef.current)
457
+ timerRef.current = setTimeout(() => setOutgoing(null), RAIL_PUSH_MS)
458
+ }
459
+ }
460
+ lastBodyRef.current = navBody
461
+ })
462
+
463
+ React.useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
464
+
358
465
  return (
359
466
  <Tooltip.Provider>
360
467
  <aside
@@ -396,45 +503,35 @@ export function Sidebar({
396
503
  )}
397
504
  </div>
398
505
 
399
- {/* Main Navigation */}
400
- <nav className="flex-1 space-y-1 overflow-y-auto p-2">
401
- {topContent && <div className="mb-4 space-y-2">{topContent}</div>}
402
-
403
- {mainItems.length > 0 && (
404
- <div className="space-y-1">
405
- {mainItems.map((item) =>
406
- item.children && item.children.length > 0 ? (
407
- <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
408
- ) : (
409
- <NavItem
410
- key={item.id}
411
- item={item}
412
- collapsed={collapsed}
413
- isActive={item.route === activeRoute}
414
- onNavigate={handleNavigate}
415
- />
416
- )
417
- )}
418
- </div>
506
+ {/* Main Navigation. During a rail swap the outgoing nav is pinned on
507
+ top of the incoming one so the two cross-slide; overflow is clipped
508
+ for the duration so neither pane can scroll the rail sideways. */}
509
+ <nav
510
+ className={cn(
511
+ 'relative flex-1 p-2',
512
+ outgoing ? 'overflow-hidden' : 'overflow-y-auto',
419
513
  )}
420
-
421
- {secondaryItems.length > 0 && (
422
- <div className="mt-6 space-y-1">
423
- {secondaryItems.map((item) =>
424
- item.children && item.children.length > 0 ? (
425
- <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
426
- ) : (
427
- <NavItem
428
- key={item.id}
429
- item={item}
430
- collapsed={collapsed}
431
- isActive={item.route === activeRoute}
432
- onNavigate={handleNavigate}
433
- />
434
- )
514
+ >
515
+ {outgoing && (
516
+ <div
517
+ aria-hidden
518
+ className={cn(
519
+ 'pointer-events-none absolute inset-x-2 top-2 space-y-1',
520
+ outgoing.direction === 'back' ? 'saas-rail-out-back' : 'saas-rail-out-forward',
435
521
  )}
522
+ >
523
+ {outgoing.body}
436
524
  </div>
437
525
  )}
526
+ <div
527
+ key={navKey}
528
+ className={cn(
529
+ 'space-y-1',
530
+ outgoing && (outgoing.direction === 'back' ? 'saas-rail-in-back' : 'saas-rail-in-forward'),
531
+ )}
532
+ >
533
+ {navBody}
534
+ </div>
438
535
  </nav>
439
536
 
440
537
  {/* Settings nav items */}
@@ -31,6 +31,8 @@ import {
31
31
  UserCog,
32
32
  BookOpen,
33
33
  BookOpenCheck,
34
+ GraduationCap,
35
+ Repeat,
34
36
  MessageCircle,
35
37
  Globe,
36
38
  Percent,
@@ -102,6 +104,8 @@ import {
102
104
  Inbox,
103
105
  Star,
104
106
  Zap,
107
+ Crown,
108
+ LogOut,
105
109
  type LucideIcon,
106
110
  } from 'lucide-react'
107
111
  import { cn } from '../utils/cn'
@@ -116,6 +120,9 @@ export interface NavigationItem {
116
120
  badge?: string | number
117
121
  children?: NavigationItem[]
118
122
  permission?: { feature: string; action: 'read' | 'create' | 'edit' | 'delete' }
123
+ /** Freemium discovery: role-allowed but not entitled by the current plan.
124
+ * Renders a small Crown badge; clicking leads to the plan's UpgradePrompt. */
125
+ premium?: boolean
119
126
  }
120
127
 
121
128
  export interface TopbarProps {
@@ -123,6 +130,8 @@ export interface TopbarProps {
123
130
  logo?: React.ReactNode
124
131
  user?: { fullName: string; avatarUrl?: string; email: string }
125
132
  onMenuClick?: () => void
133
+ /** Workspace/org switcher rendered right after the logo (desktop). */
134
+ orgSwitcher?: React.ReactNode
126
135
  leftContent?: React.ReactNode
127
136
  rightContent?: React.ReactNode
128
137
  currentPath?: string
@@ -132,17 +141,23 @@ export interface TopbarProps {
132
141
  onProfile?: () => void
133
142
  onSettings?: () => void
134
143
  onBilling?: () => void
144
+ /** Current-plan pill shown next to the user in the dropdown header. */
145
+ userPlan?: { label: string; paid: boolean }
146
+ /** i18n label for the billing/subscription menu item (host owns translation). */
147
+ billingLabel?: string
135
148
  userMenuExtras?: { label: string; icon?: React.ReactNode; onClick: () => void }[]
136
149
  }
137
150
 
138
151
  function TopbarUserMenu({
139
152
  user,
153
+ userPlan,
154
+ billingLabel = 'Subscription',
140
155
  onSignOut,
141
156
  onProfile,
142
157
  onSettings,
143
158
  onBilling,
144
159
  userMenuExtras,
145
- }: Pick<TopbarProps, 'user' | 'onSignOut' | 'onProfile' | 'onSettings' | 'onBilling' | 'userMenuExtras'>) {
160
+ }: Pick<TopbarProps, 'user' | 'userPlan' | 'billingLabel' | 'onSignOut' | 'onProfile' | 'onSettings' | 'onBilling' | 'userMenuExtras'>) {
146
161
  const [open, setOpen] = React.useState(false)
147
162
  if (!user) return null
148
163
  const initial = (user.fullName?.trim()?.[0] ?? user.email?.[0] ?? '?').toUpperCase()
@@ -185,12 +200,33 @@ function TopbarUserMenu({
185
200
  <div className="mt-1 space-y-0.5">
186
201
  {item('Perfil', onProfile, UserCog)}
187
202
  {item('Configurações', onSettings, Settings)}
188
- {item('Cobrança', onBilling, CreditCard)}
203
+ {onBilling && (
204
+ <button
205
+ key="billing"
206
+ onClick={() => { setOpen(false); onBilling() }}
207
+ className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-muted/70"
208
+ >
209
+ <CreditCard className="h-4 w-4 text-muted-foreground" />
210
+ <span className="flex-1 text-left">{billingLabel}</span>
211
+ {userPlan && (
212
+ <span
213
+ className={
214
+ userPlan.paid
215
+ ? 'inline-flex items-center gap-1 rounded-full border border-amber-400/40 bg-amber-400/15 px-2 py-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-300'
216
+ : 'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground'
217
+ }
218
+ >
219
+ {userPlan.paid ? <Crown className="h-3 w-3" /> : <Sparkles className="h-3 w-3" />}
220
+ {userPlan.label}
221
+ </span>
222
+ )}
223
+ </button>
224
+ )}
189
225
  {userMenuExtras?.map((e) => item(e.label, e.onClick))}
190
226
  {onSignOut && (
191
227
  <>
192
228
  <div className="my-1 border-t border-border" />
193
- {item('Sair', onSignOut)}
229
+ {item('Sair', onSignOut, LogOut)}
194
230
  </>
195
231
  )}
196
232
  </div>
@@ -208,7 +244,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
208
244
  Contact, Building2, ChevronDown, Filter, Plus, Shield, List, ListChecks, FolderOpen, Dog, Cat, PawPrint, Heart, LayoutTemplate, LeafyGreen, Apple, Egg, Wheat,
209
245
  ArrowDownCircle, ArrowUpCircle, ArrowLeftRight, Plug, SlidersHorizontal, TableProperties, Landmark, Receipt, TrendingUp, CircleDollarSign, Clock, AlertTriangle, Sparkles, Wallet, Warehouse, Ruler, ArrowUpRight, ArrowDownRight, UserPlus, UserX, TreePalm,
210
246
  Boxes, Box, FileCheck2, PartyPopper, Radio, CalendarDays, BadgeDollarSign, Banknote, Layers, Music, Eye, ListMusic, Disc3, UsersRound, Mic,
211
- CalendarCheck2, CalendarX, ListPlus, Map, Ban, Clock3, Inbox, UserCheck, Star, Zap, Clapperboard, Newspaper,
247
+ CalendarCheck2, CalendarX, ListPlus, Map, Ban, Clock3, Inbox, UserCheck, Star, Zap, Clapperboard, Newspaper, GraduationCap, Repeat,
212
248
  }
213
249
 
214
250
  function getIcon(name: string): LucideIcon {
@@ -282,6 +318,7 @@ export function Topbar({
282
318
  logo,
283
319
  user,
284
320
  onMenuClick,
321
+ orgSwitcher,
285
322
  leftContent,
286
323
  rightContent,
287
324
  currentPath = '/',
@@ -291,6 +328,8 @@ export function Topbar({
291
328
  onProfile,
292
329
  onSettings,
293
330
  onBilling,
331
+ userPlan,
332
+ billingLabel,
294
333
  userMenuExtras,
295
334
  }: TopbarProps) {
296
335
  const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
@@ -324,6 +363,9 @@ export function Topbar({
324
363
  <div className="hidden shrink-0 items-center md:flex">
325
364
  {logo ?? <span className="text-lg font-bold">App</span>}
326
365
  </div>
366
+ {orgSwitcher && (
367
+ <div className="hidden min-w-0 items-center md:flex">{orgSwitcher}</div>
368
+ )}
327
369
  </div>
328
370
 
329
371
  {/* Center: Search (desktop only) */}
@@ -346,6 +388,8 @@ export function Topbar({
346
388
  {rightContent}
347
389
  <TopbarUserMenu
348
390
  user={user}
391
+ userPlan={userPlan}
392
+ billingLabel={billingLabel}
349
393
  onSignOut={onSignOut}
350
394
  onProfile={onProfile}
351
395
  onSettings={onSettings}
@@ -399,6 +443,9 @@ export function Topbar({
399
443
  >
400
444
  <Icon className="h-4 w-4" />
401
445
  <span>{item.label}</span>
446
+ {item.premium && (
447
+ <Crown className="h-3 w-3 text-amber-500" aria-hidden />
448
+ )}
402
449
  {item.badge !== undefined && (
403
450
  <span className="rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
404
451
  {item.badge}
@@ -0,0 +1,216 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Search, Plus } from 'lucide-react'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Reusable search combobox with keyboard navigation + optional create
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export interface ComboboxOption {
10
+ id: string
11
+ label: string
12
+ subtitle?: string
13
+ price?: number
14
+ data?: Record<string, unknown>
15
+ }
16
+
17
+ interface SearchComboboxProps {
18
+ value: string
19
+ onChange: (value: string) => void
20
+ onSelect: (option: ComboboxOption) => void
21
+ options: ComboboxOption[]
22
+ placeholder?: string
23
+ autoFocus?: boolean
24
+ onBlurEmpty?: () => void
25
+ renderRight?: (option: ComboboxOption) => React.ReactNode
26
+ /** Show a "+ Create ..." option when search has no exact match */
27
+ allowCreate?: boolean
28
+ /** Called when user clicks the create option. Receives the current search text. */
29
+ onCreateNew?: (searchText: string) => void
30
+ /** Label for the create option (default: 'Create') */
31
+ createLabel?: string
32
+ className?: string
33
+ /** Minimal style — no border/bg, just underline on focus. For inline use. */
34
+ minimal?: boolean
35
+ /** Inline label shown when collapsed. Click reveals the search input. */
36
+ inlineLabel?: string
37
+ /** Icon for the inline label: 'search' (default) or 'plus' (for "add" actions) */
38
+ inlineIcon?: 'search' | 'plus'
39
+ /** Show a loading skeleton in the dropdown while results are being fetched */
40
+ loading?: boolean
41
+ /** Called when the input gains focus */
42
+ onFocus?: () => void
43
+ }
44
+
45
+ export function SearchCombobox({
46
+ value, onChange, onSelect, options, placeholder, autoFocus, onBlurEmpty,
47
+ renderRight, allowCreate, onCreateNew, createLabel = 'Create', className, minimal, inlineLabel, inlineIcon = 'search',
48
+ loading, onFocus: onFocusProp,
49
+ }: SearchComboboxProps) {
50
+ const [open, setOpen] = useState(false)
51
+ const [activeIndex, setActiveIndex] = useState(-1)
52
+ const [expanded, setExpanded] = useState(!inlineLabel)
53
+ const [dropRect, setDropRect] = useState<DOMRect | null>(null)
54
+ const inputRef = useRef<HTMLInputElement>(null)
55
+ const wrapperRef = useRef<HTMLDivElement>(null)
56
+ const listRef = useRef<HTMLDivElement>(null)
57
+
58
+ // When value is set externally (e.g. edit mode), auto-expand; collapse when cleared
59
+ useEffect(() => {
60
+ if (value && inlineLabel) setExpanded(true)
61
+ if (!value && inlineLabel && !open) setExpanded(false)
62
+ }, [value, open])
63
+
64
+ function updateRect() {
65
+ if (wrapperRef.current) setDropRect(wrapperRef.current.getBoundingClientRect())
66
+ }
67
+
68
+ // Show create option only when dropdown is open, has text, and no exact match
69
+ const showCreate = open && allowCreate && onCreateNew && value.trim().length > 0 &&
70
+ !options.some((o) => o.label.toLowerCase() === value.trim().toLowerCase())
71
+
72
+ const totalItems = options.length + (showCreate ? 1 : 0)
73
+
74
+ useEffect(() => { setActiveIndex(-1) }, [options])
75
+ useEffect(() => {
76
+ if ((options.length > 0 || showCreate || loading) && document.activeElement === inputRef.current) setOpen(true)
77
+ }, [options, showCreate, loading])
78
+
79
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
80
+ if (!open || totalItems === 0) {
81
+ if (e.key === 'ArrowDown' && totalItems > 0) {
82
+ e.preventDefault()
83
+ setOpen(true)
84
+ setActiveIndex(0)
85
+ }
86
+ return
87
+ }
88
+
89
+ switch (e.key) {
90
+ case 'ArrowDown':
91
+ e.preventDefault()
92
+ setActiveIndex((prev) => (prev + 1) % totalItems)
93
+ break
94
+ case 'ArrowUp':
95
+ e.preventDefault()
96
+ setActiveIndex((prev) => (prev <= 0 ? totalItems - 1 : prev - 1))
97
+ break
98
+ case 'Enter':
99
+ e.preventDefault()
100
+ if (activeIndex >= 0 && activeIndex < options.length) {
101
+ onSelect(options[activeIndex])
102
+ setOpen(false)
103
+ setActiveIndex(-1)
104
+ } else if (showCreate && activeIndex === options.length) {
105
+ onCreateNew?.(value.trim())
106
+ setOpen(false)
107
+ setActiveIndex(-1)
108
+ }
109
+ break
110
+ case 'Escape':
111
+ e.preventDefault()
112
+ setOpen(false)
113
+ setActiveIndex(-1)
114
+ break
115
+ }
116
+ }, [open, options, totalItems, activeIndex, onSelect, showCreate, onCreateNew, value])
117
+
118
+ useEffect(() => {
119
+ if (activeIndex < 0 || !listRef.current) return
120
+ const items = listRef.current.children
121
+ if (items[activeIndex]) (items[activeIndex] as HTMLElement).scrollIntoView({ block: 'nearest' })
122
+ }, [activeIndex])
123
+
124
+ function handleBlur() {
125
+ setTimeout(() => {
126
+ setOpen(false)
127
+ setActiveIndex(-1)
128
+ if (!value && onBlurEmpty) onBlurEmpty()
129
+ if (!value && inlineLabel) setExpanded(false)
130
+ }, 200)
131
+ }
132
+
133
+ const hasDropdown = open && (options.length > 0 || showCreate || loading)
134
+
135
+ // Collapsed inline label — click to expand
136
+ if (!expanded && inlineLabel) {
137
+ const isPlus = inlineIcon === 'plus'
138
+ return (
139
+ <button type="button" onClick={() => { setExpanded(true); setTimeout(() => inputRef.current?.focus(), 50) }}
140
+ className={`flex items-center gap-1.5 w-full text-left text-sm py-1.5 pl-2 transition-colors ${
141
+ isPlus ? 'text-primary hover:text-primary/80' : 'text-muted-foreground hover:text-foreground'
142
+ } ${className ?? ''}`}>
143
+ {isPlus && <Plus className="h-3.5 w-3.5 shrink-0" />}
144
+ {inlineLabel}
145
+ </button>
146
+ )
147
+ }
148
+
149
+ return (
150
+ <div ref={wrapperRef} className={`relative ${className ?? ''}`}>
151
+ {!minimal && (
152
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
153
+ )}
154
+ <input
155
+ ref={inputRef}
156
+ type="text"
157
+ value={value}
158
+ onChange={(e) => { onChange(e.target.value); updateRect(); setOpen(true) }}
159
+ onFocus={() => { updateRect(); onFocusProp?.(); if (options.length > 0 || showCreate || loading) setOpen(true) }}
160
+ onBlur={handleBlur}
161
+ onKeyDown={handleKeyDown}
162
+ placeholder={placeholder}
163
+ autoFocus={autoFocus}
164
+ className={minimal
165
+ ? 'w-full bg-transparent pl-2 pr-3 py-1.5 text-sm placeholder:text-muted-foreground focus:outline-none border-b border-transparent hover:border-border focus:border-foreground transition-colors'
166
+ : 'w-full rounded-lg border bg-background pl-8 pr-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
167
+ }
168
+ role="combobox"
169
+ aria-expanded={hasDropdown}
170
+ aria-autocomplete="list"
171
+ aria-activedescendant={activeIndex >= 0 ? `combobox-opt-${activeIndex}` : undefined}
172
+ />
173
+ {hasDropdown && dropRect && createPortal(
174
+ <div ref={listRef} role="listbox" data-modal-passthrough
175
+ style={{ position: 'fixed', left: dropRect.left, top: dropRect.bottom + 4, width: dropRect.width, zIndex: 9999, pointerEvents: 'auto' }}
176
+ className="fayz-glass-surface max-h-52 overflow-y-auto rounded-lg border bg-popover shadow-lg py-0.5 animate-in fade-in zoom-in-95 duration-100">
177
+ {loading && options.length === 0 && (
178
+ <div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
179
+ <svg className="h-3.5 w-3.5 animate-spin shrink-0" viewBox="0 0 16 16" fill="none">
180
+ <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" opacity="0.25" />
181
+ <path d="M14.5 8a6.5 6.5 0 00-6.5-6.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
182
+ </svg>
183
+ <span>Buscando…</span>
184
+ </div>
185
+ )}
186
+ {options.map((opt, i) => (
187
+ <div key={opt.id} id={`combobox-opt-${i}`} role="option" aria-selected={i === activeIndex}
188
+ onMouseDown={(e) => { e.preventDefault(); onSelect(opt); setOpen(false) }}
189
+ onMouseEnter={() => setActiveIndex(i)}
190
+ className={`flex w-full items-center gap-2 px-3 py-2 text-sm cursor-pointer transition-colors ${
191
+ i === activeIndex ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
192
+ }`}>
193
+ <div className="flex-1 min-w-0">
194
+ <p className="truncate font-medium">{opt.label}</p>
195
+ {opt.subtitle && <p className="truncate text-[11px] text-muted-foreground">{opt.subtitle}</p>}
196
+ </div>
197
+ {renderRight && <div className="shrink-0">{renderRight(opt)}</div>}
198
+ </div>
199
+ ))}
200
+ {showCreate && (
201
+ <div id={`combobox-opt-${options.length}`} role="option" aria-selected={activeIndex === options.length}
202
+ onMouseDown={(e) => { e.preventDefault(); onCreateNew?.(value.trim()); setOpen(false) }}
203
+ onMouseEnter={() => setActiveIndex(options.length)}
204
+ className={`flex w-full items-center gap-2 px-3 py-2 text-sm cursor-pointer transition-colors border-t ${
205
+ activeIndex === options.length ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
206
+ }`}>
207
+ <Plus className="h-3.5 w-3.5 shrink-0" />
208
+ <span>{createLabel} &ldquo;<span className="font-medium">{value.trim()}</span>&rdquo;</span>
209
+ </div>
210
+ )}
211
+ </div>,
212
+ document.body,
213
+ )}
214
+ </div>
215
+ )
216
+ }
@@ -35,7 +35,12 @@ function DataTable<TData, TValue>({
35
35
  compact = false,
36
36
  }: DataTableProps<TData, TValue>) {
37
37
  const isCard = variant === 'card'
38
- const wrapperCls = isCard ? 'rounded-lg border bg-card shadow-sm overflow-x-auto' : 'rounded-lg border overflow-x-auto'
38
+ // Rounded box and scroll container must be SEPARATE elements: a scroll
39
+ // container paints its content over its own border-radius, which squared
40
+ // the corners the moment a row background met them.
41
+ const wrapperCls = isCard
42
+ ? 'overflow-hidden rounded-lg border bg-card shadow-sm'
43
+ : 'overflow-hidden rounded-lg border'
39
44
  const headerCls = isCard ? 'border-b bg-muted/50' : 'border-b bg-muted/30'
40
45
  const thCls = compact ? 'px-3 py-1.5 text-left text-[11px] font-medium text-muted-foreground' : 'px-4 py-2.5 text-left font-medium text-muted-foreground'
41
46
  const tdCls = compact ? 'px-3 py-1.5 align-middle text-xs' : 'px-4 py-3 align-middle'
@@ -55,6 +60,7 @@ function DataTable<TData, TValue>({
55
60
  if (loading) {
56
61
  return (
57
62
  <div className={wrapperCls}>
63
+ <div className="overflow-x-auto">
58
64
  <table className={tableCls}>
59
65
  <thead>
60
66
  <tr className={headerCls}>
@@ -77,12 +83,14 @@ function DataTable<TData, TValue>({
77
83
  ))}
78
84
  </tbody>
79
85
  </table>
86
+ </div>
80
87
  </div>
81
88
  )
82
89
  }
83
90
 
84
91
  return (
85
92
  <div className={wrapperCls}>
93
+ <div className="overflow-x-auto">
86
94
  <table className={tableCls}>
87
95
  <thead>
88
96
  {table.getHeaderGroups().map((headerGroup) => (
@@ -144,6 +152,7 @@ function DataTable<TData, TValue>({
144
152
  )}
145
153
  </tbody>
146
154
  </table>
155
+ </div>
147
156
  </div>
148
157
  )
149
158
  }
@@ -83,3 +83,4 @@ export { Popover, PopoverTrigger, PopoverContent } from './popover'
83
83
  export { SegmentedControl, type SegmentedControlProps, type SegmentedOption } from './segmented-control'
84
84
  export { Markdown, renderMarkdown, type MarkdownProps } from './markdown'
85
85
  export { MarkdownEditor, type MarkdownEditorProps } from './markdown-editor'
86
+ export { SearchCombobox, type ComboboxOption } from './SearchCombobox'