@open-mercato/ui 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

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 (67) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/records/ActivityCard.js +4 -3
  3. package/dist/ai/records/ActivityCard.js.map +2 -2
  4. package/dist/ai/records/DealCard.js +4 -3
  5. package/dist/ai/records/DealCard.js.map +2 -2
  6. package/dist/ai/records/ProductCard.js +3 -2
  7. package/dist/ai/records/ProductCard.js.map +2 -2
  8. package/dist/backend/AppShell.js +129 -106
  9. package/dist/backend/AppShell.js.map +2 -2
  10. package/dist/backend/DataTable.js +20 -7
  11. package/dist/backend/DataTable.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +26 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/icons/lucideRegistry.generated.js +4 -0
  15. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  16. package/dist/backend/injection/useRegisteredComponent.js +24 -6
  17. package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
  18. package/dist/backend/inputs/ComboboxInput.js +8 -4
  19. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +8 -8
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js +36 -30
  23. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  24. package/dist/backend/utils/crud.js.map +2 -2
  25. package/dist/primitives/alert.js +1 -1
  26. package/dist/primitives/alert.js.map +2 -2
  27. package/dist/primitives/icon-button.js +20 -1
  28. package/dist/primitives/icon-button.js.map +2 -2
  29. package/dist/primitives/pagination.js +14 -9
  30. package/dist/primitives/pagination.js.map +2 -2
  31. package/dist/primitives/rich-editor.js +14 -7
  32. package/dist/primitives/rich-editor.js.map +2 -2
  33. package/dist/utils/format.js +5 -5
  34. package/dist/utils/format.js.map +2 -2
  35. package/package.json +5 -4
  36. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +2 -0
  37. package/src/ai/records/ActivityCard.tsx +4 -3
  38. package/src/ai/records/DealCard.tsx +4 -3
  39. package/src/ai/records/ProductCard.tsx +3 -2
  40. package/src/backend/AppShell.tsx +89 -58
  41. package/src/backend/DataTable.tsx +44 -10
  42. package/src/backend/__tests__/AppShell.test.tsx +162 -1
  43. package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
  44. package/src/backend/__tests__/DataTable.headerWrap.test.tsx +111 -0
  45. package/src/backend/__tests__/DataTable.render.test.tsx +37 -0
  46. package/src/backend/__tests__/NotesSection.test.tsx +89 -0
  47. package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
  48. package/src/backend/detail/NotesSection.tsx +92 -67
  49. package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
  50. package/src/backend/injection/useRegisteredComponent.tsx +49 -8
  51. package/src/backend/inputs/ComboboxInput.tsx +21 -5
  52. package/src/backend/inputs/LookupSelect.tsx +8 -8
  53. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +142 -0
  54. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
  55. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +14 -11
  56. package/src/backend/utils/crud.ts +2 -0
  57. package/src/primitives/__tests__/alert.test.tsx +41 -1
  58. package/src/primitives/__tests__/icon-button.test.tsx +70 -0
  59. package/src/primitives/__tests__/notification.test.tsx +1 -1
  60. package/src/primitives/__tests__/pagination.test.tsx +54 -0
  61. package/src/primitives/__tests__/rich-editor.test.tsx +43 -0
  62. package/src/primitives/alert.tsx +5 -2
  63. package/src/primitives/icon-button.tsx +20 -1
  64. package/src/primitives/pagination.tsx +40 -9
  65. package/src/primitives/rich-editor.tsx +9 -4
  66. package/src/utils/__tests__/format.test.ts +27 -14
  67. package/src/utils/format.ts +10 -5
@@ -411,6 +411,19 @@ function resolveItemKey(item: { id?: string; href: string }): string {
411
411
  return item.href
412
412
  }
413
413
 
414
+ // Horizontal padding lives on each sidebar block rather than on the <aside>, so the scroll
415
+ // container never needs a negative-margin bleed to reach the panel edges. The transition keeps the
416
+ // compact/expanded padding step in sync with the aside's width animation.
417
+ function sidebarBlockPadding(compact: boolean): string {
418
+ return `${compact ? 'px-2' : 'px-3'} transition-[padding] duration-200 ease-out`
419
+ }
420
+
421
+ // An InjectionSpot renders nothing when no widget is registered, so its wrapper must collapse
422
+ // instead of surviving as an empty flex child that still costs a full `gap-3`.
423
+ function sidebarInjectionWrapper(compact: boolean): string {
424
+ return `${sidebarBlockPadding(compact)} empty:hidden`
425
+ }
426
+
414
427
  function SerializedIcon({ markup }: { markup: string }) {
415
428
  return <span aria-hidden="true" dangerouslySetInnerHTML={{ __html: markup }} />
416
429
  }
@@ -772,17 +785,9 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
772
785
  setOpenGroups((prev) => (prev[key] === false ? { ...prev, [key]: true } : prev))
773
786
  // eslint-disable-next-line react-hooks/exhaustive-deps
774
787
  }, [pathname, navGroups])
775
- // Keep header state in sync with props (server-side updates)
776
- React.useEffect(() => {
777
- setHeaderTitle(currentTitle)
778
- setHeaderBreadcrumb(breadcrumb)
779
- }, [currentTitle, breadcrumb])
780
- // Clear breadcrumb on client-side navigation so stale state doesn't persist;
781
- // the new page's ApplyBreadcrumb (if any) will set the correct values.
782
- // Must be a layout effect: when a prefetched navigation commits the new
783
- // pathname and the new page together, child passive effects (ApplyBreadcrumb)
784
- // run before parent ones, so a passive clear here would wipe the value the
785
- // incoming page just set.
788
+ // Reset stale header state before applying server props and before the new
789
+ // page's passive ApplyBreadcrumb effect. The ordering matters for layouts
790
+ // that persist across client-side navigation.
786
791
  const prevPathname = React.useRef(pathname)
787
792
  useIsomorphicLayoutEffect(() => {
788
793
  if (pathname !== prevPathname.current) {
@@ -791,6 +796,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
791
796
  setHeaderBreadcrumb(undefined)
792
797
  }
793
798
  }, [pathname])
799
+ // Keep header state in sync with props (server-side updates). A child page's
800
+ // ApplyBreadcrumb effect runs afterward and may refine this static trail.
801
+ useIsomorphicLayoutEffect(() => {
802
+ setHeaderTitle(currentTitle)
803
+ setHeaderBreadcrumb(breadcrumb)
804
+ }, [currentTitle, breadcrumb])
794
805
 
795
806
  // Keep navGroups in sync when server-provided groups change
796
807
  React.useEffect(() => {
@@ -806,11 +817,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
806
817
  ) {
807
818
  const sortedSections = [...sections].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
808
819
  const lastVisibleIndex = sortedSections.length - 1
820
+ const blockPadding = sidebarBlockPadding(compact)
809
821
 
810
822
  return (
811
823
  <div className="flex h-full flex-col gap-3">
812
824
  {!hideHeader && (
813
- <div className="mb-2">
825
+ <div className={`mb-2 ${blockPadding}`}>
814
826
  <Link
815
827
  href="/backend"
816
828
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
@@ -822,16 +834,18 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
822
834
  </div>
823
835
  )}
824
836
  {!compact && !hideSearch && (
825
- <SearchInput
826
- value={navQuery}
827
- onChange={setNavQuery}
828
- placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
829
- aria-label={t('appShell.searchNavAria', 'Search navigation')}
830
- clearLabel={t('appShell.searchNavClear', 'Clear search')}
831
- className="mb-2"
832
- />
837
+ <div className={blockPadding}>
838
+ <SearchInput
839
+ value={navQuery}
840
+ onChange={setNavQuery}
841
+ placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
842
+ aria-label={t('appShell.searchNavAria', 'Search navigation')}
843
+ clearLabel={t('appShell.searchNavClear', 'Clear search')}
844
+ className="mb-2"
845
+ />
846
+ </div>
833
847
  )}
834
- <div data-sidebar-scroll="true" className={`flex flex-1 flex-col gap-3 overflow-y-auto scrollbar-hide pr-1 ${compact ? '-ml-2 pl-2' : '-ml-3 pl-3'}`}>
848
+ <div data-sidebar-scroll="true" className="flex flex-1 flex-col gap-3 overflow-x-hidden overflow-y-auto scrollbar-hide">
835
849
  <nav className="flex flex-col gap-2">
836
850
  {sortedSections.map((section, sectionIndex) => {
837
851
  const sectionNavQueryActive = hideSearch ? false : navQueryActive
@@ -911,7 +925,11 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
911
925
  }
912
926
 
913
927
  return (
914
- <div key={section.id}>
928
+ <div
929
+ key={section.id}
930
+ className={`${blockPadding} ${sectionIndex !== lastVisibleIndex ? 'border-b pb-2' : ''}`}
931
+ data-sidebar-group="true"
932
+ >
915
933
  {!compact && (
916
934
  <Button
917
935
  variant="muted"
@@ -928,7 +946,6 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
928
946
  {sortedItems.map((item) => renderSectionItem(item))}
929
947
  </div>
930
948
  )}
931
- {sectionIndex !== lastVisibleIndex && <div className={`my-2 border-t ${compact ? '-ml-2 -mr-3' : '-ml-3 -mr-4'}`} />}
932
949
  </div>
933
950
  )
934
951
  })}
@@ -939,9 +956,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
939
956
  }
940
957
 
941
958
  function renderSidebar(compact: boolean, hideHeader?: boolean, forceMainOnly?: boolean) {
959
+ const blockPadding = sidebarBlockPadding(compact)
960
+ const injectionWrapper = sidebarInjectionWrapper(compact)
961
+
942
962
  if (!isChromeReady && isChromeLoading) {
943
963
  return (
944
- <div className="flex flex-col min-h-full gap-3" data-testid="backend-chrome-loading">
964
+ <div className={`flex min-h-full flex-col gap-3 ${blockPadding}`} data-testid="backend-chrome-loading">
945
965
  {!hideHeader ? (
946
966
  <div className="mb-2">
947
967
  <Link
@@ -954,7 +974,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
954
974
  </Link>
955
975
  </div>
956
976
  ) : null}
957
- <div className="flex flex-1 flex-col gap-3 pr-1">
977
+ <div className="flex flex-1 flex-col gap-3">
958
978
  <div className="space-y-3">
959
979
  <div className="h-8 rounded bg-muted/50" />
960
980
  <div className="space-y-2 pl-1">
@@ -1009,7 +1029,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1009
1029
  return (
1010
1030
  <div className="flex h-full flex-col gap-3">
1011
1031
  {!hideHeader && (
1012
- <div className="mb-2">
1032
+ <div className={`mb-2 ${blockPadding}`}>
1013
1033
  <Link
1014
1034
  href="/backend"
1015
1035
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
@@ -1021,22 +1041,26 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1021
1041
  </div>
1022
1042
  )}
1023
1043
  {shouldRenderSidebarInjectionSpots ? (
1024
- <InjectionSpot
1025
- spotId={BACKEND_SIDEBAR_TOP_INJECTION_SPOT_ID}
1026
- context={injectionContext}
1027
- />
1044
+ <div className={injectionWrapper} data-sidebar-injection-wrapper="true">
1045
+ <InjectionSpot
1046
+ spotId={BACKEND_SIDEBAR_TOP_INJECTION_SPOT_ID}
1047
+ context={injectionContext}
1048
+ />
1049
+ </div>
1028
1050
  ) : null}
1029
1051
  {!compact && (
1030
- <SearchInput
1031
- value={navQuery}
1032
- onChange={setNavQuery}
1033
- placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
1034
- aria-label={t('appShell.searchNavAria', 'Search navigation')}
1035
- clearLabel={t('appShell.searchNavClear', 'Clear search')}
1036
- className="mb-2"
1037
- />
1052
+ <div className={blockPadding}>
1053
+ <SearchInput
1054
+ value={navQuery}
1055
+ onChange={setNavQuery}
1056
+ placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
1057
+ aria-label={t('appShell.searchNavAria', 'Search navigation')}
1058
+ clearLabel={t('appShell.searchNavClear', 'Clear search')}
1059
+ className="mb-2"
1060
+ />
1061
+ </div>
1038
1062
  )}
1039
- <div data-sidebar-scroll="true" className={`flex flex-1 flex-col gap-3 overflow-y-auto scrollbar-hide pr-1 ${compact ? '-ml-2 pl-2' : '-ml-3 pl-3'}`}>
1063
+ <div data-sidebar-scroll="true" className="flex flex-1 flex-col gap-3 overflow-x-hidden overflow-y-auto scrollbar-hide">
1040
1064
  {(() => {
1041
1065
  const isSettingsPath = (href: string) => {
1042
1066
  if (href === '/backend/settings') return true
@@ -1065,10 +1089,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1065
1089
  <>
1066
1090
  <nav className="flex flex-col gap-2" data-testid="sidebar">
1067
1091
  {shouldRenderSidebarInjectionSpots ? (
1068
- <InjectionSpot
1069
- spotId={BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID}
1070
- context={injectionContext}
1071
- />
1092
+ <div className={injectionWrapper} data-sidebar-injection-wrapper="true">
1093
+ <InjectionSpot
1094
+ spotId={BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID}
1095
+ context={injectionContext}
1096
+ />
1097
+ </div>
1072
1098
  ) : null}
1073
1099
  {mainGroups.map((g, gi) => {
1074
1100
  const groupId = resolveGroupKey(g)
@@ -1082,7 +1108,11 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1082
1108
  })
1083
1109
  if (visibleItems.length === 0) return null
1084
1110
  return (
1085
- <div key={groupId}>
1111
+ <div
1112
+ className={`${blockPadding} ${gi !== mainLastVisibleGroupIndex ? 'border-b pb-2' : ''}`}
1113
+ key={groupId}
1114
+ data-sidebar-group="true"
1115
+ >
1086
1116
  {!compact && (
1087
1117
  <Button
1088
1118
  variant="muted"
@@ -1175,7 +1205,6 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1175
1205
  })}
1176
1206
  </div>
1177
1207
  )}
1178
- {gi !== mainLastVisibleGroupIndex && <div className={`my-2 border-t ${compact ? '-ml-2 -mr-3' : '-ml-3 -mr-4'}`} />}
1179
1208
  </div>
1180
1209
  )
1181
1210
  })}
@@ -1184,7 +1213,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1184
1213
  )
1185
1214
  })()}
1186
1215
  </div>
1187
- <div className="sticky bottom-0 bg-background pb-1">
1216
+ <div className={`sticky bottom-0 bg-background pb-1 ${injectionWrapper}`} data-sidebar-injection-wrapper="true">
1188
1217
  {shouldRenderSidebarInjectionSpots ? (
1189
1218
  <InjectionSpot
1190
1219
  spotId={BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID}
@@ -1229,15 +1258,17 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1229
1258
  if (!sections) return null
1230
1259
  return (
1231
1260
  <div className="flex h-full flex-col gap-2">
1232
- <Link
1233
- href="/backend"
1234
- className="inline-flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
1235
- data-testid="appshell-section-back-to-main"
1236
- aria-label={t('backend.nav.backToMain', 'Back to Main')}
1237
- >
1238
- <ChevronLeft className="size-4 shrink-0" aria-hidden />
1239
- <span className="truncate">{title}</span>
1240
- </Link>
1261
+ <div className={sidebarBlockPadding(false)}>
1262
+ <Link
1263
+ href="/backend"
1264
+ className="inline-flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
1265
+ data-testid="appshell-section-back-to-main"
1266
+ aria-label={t('backend.nav.backToMain', 'Back to Main')}
1267
+ >
1268
+ <ChevronLeft className="size-4 shrink-0" aria-hidden />
1269
+ <span className="truncate">{title}</span>
1270
+ </Link>
1271
+ </div>
1241
1272
  <div className="min-h-0 flex-1">
1242
1273
  {renderSectionSidebar(sections, title, false, true, true)}
1243
1274
  </div>
@@ -1306,7 +1337,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1306
1337
  {effectiveCollapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
1307
1338
  </button>
1308
1339
  {/* Desktop main sidebar */}
1309
- <aside ref={sidebarAsideRef} className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative transition-[width,padding] duration-200 ease-out`} style={{ width: asideWidth }}>
1340
+ <aside ref={sidebarAsideRef} className={`${asideClassesBase} px-0 hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative transition-[width] duration-200 ease-out`} style={{ width: asideWidth }}>
1310
1341
  {renderSidebar(effectiveCollapsed, false, isSectionView)}
1311
1342
  {/* Scroll affordance — gradient fade + clickable chevron that flips up when
1312
1343
  the user reaches the bottom and disappears when nothing is scrollable
@@ -1351,7 +1382,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1351
1382
  original swap behavior to fit the narrow width. */}
1352
1383
  {isSectionView ? (
1353
1384
  <aside
1354
- className={`${asideClassesBase} px-3 hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative`}
1385
+ className={`${asideClassesBase} px-0 hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative`}
1355
1386
  style={{ width: '240px' }}
1356
1387
  data-testid="appshell-section-sidebar"
1357
1388
  >
@@ -1573,7 +1604,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1573
1604
  ? `mobile-drawer-tab-${mobileDrawerView === 'main' ? 'main' : 'section'}`
1574
1605
  : undefined
1575
1606
  }
1576
- className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-3"
1607
+ className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3"
1577
1608
  >
1578
1609
  {/* Force expanded sidebar in mobile drawer, hide its header and collapse toggle */}
1579
1610
  {renderSidebar(false, true, mobileDrawerView === 'main')}
@@ -128,6 +128,14 @@ export type PaginationProps = {
128
128
  pageSize: number
129
129
  total: number
130
130
  totalPages: number
131
+ /**
132
+ * `total` (and the `totalPages` derived from it) is a floor, not an exact
133
+ * count — the server capped the list count (`totalIsCapped: true` on the
134
+ * list payload). Capped totals render as "{total}+" and pagination stays
135
+ * open past the floor via short-page detection instead of ending at
136
+ * `ceil(total / pageSize)`.
137
+ */
138
+ totalIsCapped?: boolean
131
139
  onPageChange: (page: number) => void
132
140
  durationMs?: number | null
133
141
  cacheStatus?: 'hit' | 'miss' | null
@@ -301,7 +309,7 @@ export type DataTableProps<T extends RowData> = {
301
309
  emptyState?: React.ReactNode
302
310
  error?: React.ReactNode | string | null
303
311
  rowActions?: (row: T) => React.ReactNode
304
- onRowClick?: (row: T) => void
312
+ onRowClick?: (row: T, event: React.MouseEvent<HTMLTableRowElement>) => void
305
313
  rowClickActionIds?: string[]
306
314
  disableRowClick?: boolean
307
315
  bulkActions?: BulkAction<T>[]
@@ -2613,8 +2621,21 @@ export function DataTable<T extends RowData>({
2613
2621
  if (!pagination || pagination.total === 0) return null
2614
2622
 
2615
2623
  const { page, totalPages, onPageChange, durationMs, cacheStatus } = pagination
2624
+ const totalIsCapped = pagination.totalIsCapped === true
2625
+ // Short-page detection: a full current page means a next page may exist,
2626
+ // even past the capped floor. `data` holds exactly the rendered page's rows.
2627
+ const pageIsFull = data.length >= pagination.pageSize
2616
2628
  const startItem = (page - 1) * pagination.pageSize + 1
2617
- const endItem = Math.min(page * pagination.pageSize, pagination.total)
2629
+ // Past a capped floor, `total` can sit below the window — derive the end
2630
+ // of the range from the rows actually shown instead.
2631
+ const endItem = totalIsCapped
2632
+ ? Math.max(startItem, startItem + data.length - 1)
2633
+ : Math.min(page * pagination.pageSize, pagination.total)
2634
+ // Short-page detection false-positives when the true row count is an exact
2635
+ // multiple of `pageSize`: Next stays enabled on the last full page and the
2636
+ // page after it comes back empty. `total` is the cap rather than 0, so the
2637
+ // pager still renders — claim no range rather than "X to X" over no rows.
2638
+ const pageIsEmpty = data.length === 0
2618
2639
  const effectiveDuration = (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0)
2619
2640
  ? durationMs
2620
2641
  : measuredDurationMs ?? undefined
@@ -2653,17 +2674,27 @@ export function DataTable<T extends RowData>({
2653
2674
  page={page}
2654
2675
  pageSize={pagination.pageSize}
2655
2676
  total={pagination.total}
2677
+ totalIsCapped={totalIsCapped}
2678
+ hasNextPage={totalIsCapped ? pageIsFull : undefined}
2656
2679
  onPageChange={(next) => { onPageChange(next); scrollTableIntoView() }}
2657
2680
  onPageSizeChange={pagination.onPageSizeChange ? (next) => {
2658
2681
  pagination.onPageSizeChange!(next)
2659
2682
  scrollTableIntoView()
2660
2683
  } : undefined}
2661
2684
  pageSizeOptions={pageSizeOptions}
2662
- formatPageInfo={() =>
2663
- durationLabel
2685
+ formatPageInfo={() => {
2686
+ if (totalIsCapped) {
2687
+ if (pageIsEmpty) {
2688
+ return t('ui.dataTable.pagination.resultsCappedNoRows', 'No further results past {total}', { total: pagination.total })
2689
+ }
2690
+ return durationLabel
2691
+ ? t('ui.dataTable.pagination.resultsCappedWithDuration', 'Showing {start} to {end} of {total}+ results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
2692
+ : t('ui.dataTable.pagination.resultsCapped', 'Showing {start} to {end} of {total}+ results', { start: startItem, end: endItem, total: pagination.total })
2693
+ }
2694
+ return durationLabel
2664
2695
  ? t('ui.dataTable.pagination.resultsWithDuration', 'Showing {start} to {end} of {total} results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
2665
2696
  : t('ui.dataTable.pagination.results', 'Showing {start} to {end} of {total} results', { start: startItem, end: endItem, total: pagination.total })
2666
- }
2697
+ }}
2667
2698
  formatPageSizeLabel={(size) =>
2668
2699
  `${size} ${t('ui.dataTable.pagination.perPage', 'per page')}`
2669
2700
  }
@@ -2672,7 +2703,7 @@ export function DataTable<T extends RowData>({
2672
2703
  />
2673
2704
  </div>
2674
2705
  )
2675
- }, [pagination, showQueryTime, measuredDurationMs, scrollTableIntoView, t])
2706
+ }, [pagination, data, showQueryTime, measuredDurationMs, scrollTableIntoView, t])
2676
2707
 
2677
2708
  // Auto filters: fetch custom field defs when requested
2678
2709
  const resolvedEntityIds = React.useMemo(() => {
@@ -3193,7 +3224,10 @@ export function DataTable<T extends RowData>({
3193
3224
  const shouldRenderHeader = hasTitle || renderToolbarInline || shouldRenderActionsWrapper || shouldRenderToolbarBelow
3194
3225
  const containerClassName = embedded ? '' : 'rounded-lg border bg-card mx-1 sm:mx-2'
3195
3226
  const headerWrapperClassName = embedded ? 'pb-3' : 'px-4 py-3 border-b'
3196
- const headerContentClassName = 'flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'
3227
+ // The header row wraps once the actions no longer fit beside the title (the
3228
+ // title keeps a 12rem floor); before, the title collapsed to a sliver and
3229
+ // the wrapped action buttons rendered over it on narrow layouts.
3230
+ const headerContentClassName = 'flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between'
3197
3231
  const toolbarWrapperClassName = embedded ? 'mt-2' : 'mt-3 pt-3 border-t'
3198
3232
  const tableScrollWrapperClassName = embedded ? '' : 'overflow-auto'
3199
3233
 
@@ -3249,11 +3283,11 @@ export function DataTable<T extends RowData>({
3249
3283
  <div className={headerWrapperClassName}>
3250
3284
  {(hasTitle || shouldRenderActionsWrapper || renderToolbarInline) && (
3251
3285
  <div className={headerContentClassName}>
3252
- <div className="flex-1 min-w-0">
3286
+ <div className="flex-1 min-w-0 sm:basis-48">
3253
3287
  {renderToolbarInline ? builtToolbar : titleContent}
3254
3288
  </div>
3255
3289
  {shouldRenderActionsWrapper ? (
3256
- <div className="flex flex-wrap items-center gap-2 min-h-[2.25rem]">
3290
+ <div className="flex flex-wrap items-center gap-2 min-h-[2.25rem] sm:ml-auto sm:justify-end">
3257
3291
  {refreshButtonConfig ? (
3258
3292
  <Button
3259
3293
  type="button"
@@ -3464,7 +3498,7 @@ export function DataTable<T extends RowData>({
3464
3498
  }
3465
3499
 
3466
3500
  if (onRowClick) {
3467
- onRowClick(row.original as T)
3501
+ onRowClick(row.original as T, e)
3468
3502
  } else if (defaultRowAction) {
3469
3503
  if (defaultRowAction.href) {
3470
3504
  router.push(defaultRowAction.href)
@@ -9,6 +9,7 @@ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWith
9
9
 
10
10
  const mockInjectionSpot = jest.fn()
11
11
  let mockPathname = '/backend/users'
12
+ let mockInjectionSpotRendersNothing = false
12
13
 
13
14
  jest.mock('next/link', () => {
14
15
  const React = require('react')
@@ -39,6 +40,7 @@ jest.mock('next/navigation', () => ({
39
40
  jest.mock('../injection/InjectionSpot', () => ({
40
41
  InjectionSpot: (props: { spotId: string; context?: Record<string, unknown> }) => {
41
42
  mockInjectionSpot(props)
43
+ if (mockInjectionSpotRendersNothing) return null
42
44
  return <div data-testid={`injection-spot:${props.spotId}`} />
43
45
  },
44
46
  }))
@@ -55,7 +57,9 @@ jest.mock('../injection/eventBridge', () => ({
55
57
  }))
56
58
 
57
59
  jest.mock('../injection/StatusBadgeInjectionSpot', () => ({
58
- StatusBadgeInjectionSpot: () => <div data-testid="status-badge-injection-spot" />,
60
+ StatusBadgeInjectionSpot: () => (
61
+ mockInjectionSpotRendersNothing ? null : <div data-testid="status-badge-injection-spot" />
62
+ ),
59
63
  }))
60
64
 
61
65
  jest.mock('../operations/LastOperationBanner', () => ({
@@ -117,6 +121,7 @@ describe('AppShell', () => {
117
121
  beforeEach(() => {
118
122
  mockInjectionSpot.mockClear()
119
123
  mockPathname = '/backend/users'
124
+ mockInjectionSpotRendersNothing = false
120
125
  })
121
126
 
122
127
  beforeAll(() => {
@@ -471,6 +476,52 @@ describe('AppShell', () => {
471
476
  })
472
477
  })
473
478
 
479
+ it('keeps the new page breadcrumb after client-side navigation', async () => {
480
+ mockPathname = '/backend/documents'
481
+
482
+ const { rerender } = renderWithProviders(
483
+ <AppShell
484
+ email="demo@example.com"
485
+ groups={groups}
486
+ currentTitle="Documents"
487
+ breadcrumb={[{ label: 'Documents' }]}
488
+ >
489
+ <ApplyBreadcrumb title="Documents" breadcrumb={[{ label: 'Documents' }]} />
490
+ <div>Documents list</div>
491
+ </AppShell>,
492
+ { dict },
493
+ )
494
+
495
+ await waitFor(() => {
496
+ expect(within(screen.getByRole('navigation', { name: 'Breadcrumb' })).getByText('Documents')).toBeInTheDocument()
497
+ })
498
+
499
+ mockPathname = '/backend/documents/document-id'
500
+ rerender(
501
+ <AppShell
502
+ email="demo@example.com"
503
+ groups={groups}
504
+ currentTitle="Documents"
505
+ breadcrumb={[{ label: 'Documents' }]}
506
+ >
507
+ <ApplyBreadcrumb
508
+ title="Document"
509
+ breadcrumb={[
510
+ { label: 'Documents', href: '/backend/documents' },
511
+ { label: 'Document' },
512
+ ]}
513
+ />
514
+ <div>Document detail</div>
515
+ </AppShell>,
516
+ )
517
+
518
+ await waitFor(() => {
519
+ const breadcrumb = screen.getByRole('navigation', { name: 'Breadcrumb' })
520
+ expect(within(breadcrumb).getByRole('link', { name: 'Documents' })).toHaveAttribute('href', '/backend/documents')
521
+ expect(within(breadcrumb).getByText('Document')).toHaveAttribute('aria-current', 'page')
522
+ })
523
+ })
524
+
474
525
  it('keeps settings parent item active on descendant routes outside explicit child list', async () => {
475
526
  mockPathname = '/backend/entities/user/example%3Acalendar_entity'
476
527
 
@@ -577,6 +628,116 @@ describe('AppShell', () => {
577
628
  }
578
629
  })
579
630
 
631
+ describe('sidebar layout', () => {
632
+ const multiGroups = [
633
+ groups[0],
634
+ {
635
+ id: 'commerce',
636
+ name: 'Commerce',
637
+ items: [{ href: '/backend/orders', title: 'Orders' }],
638
+ },
639
+ {
640
+ id: 'ops',
641
+ name: 'Ops',
642
+ items: [{ href: '/backend/jobs', title: 'Jobs' }],
643
+ },
644
+ ]
645
+
646
+ it('collapses sidebar injection-spot wrappers when their spots render nothing', async () => {
647
+ mockInjectionSpotRendersNothing = true
648
+
649
+ const { container } = renderWithProviders(
650
+ <AppShell email="demo@example.com" groups={groups}>
651
+ <div>Child content</div>
652
+ </AppShell>,
653
+ { dict },
654
+ )
655
+
656
+ await waitFor(() => {
657
+ expect(screen.getByText('Users List')).toBeInTheDocument()
658
+ })
659
+
660
+ const wrappers = Array.from(container.querySelectorAll('[data-sidebar-injection-wrapper="true"]'))
661
+ expect(wrappers.length).toBeGreaterThan(0)
662
+ for (const wrapper of wrappers) {
663
+ // The spot rendered nothing, so the wrapper is an empty flex child of the sidebar's
664
+ // `gap-3` column and would still cost a full gap unless it collapses itself.
665
+ expect(wrapper.children).toHaveLength(0)
666
+ expect(wrapper).toHaveClass('empty:hidden')
667
+ }
668
+ })
669
+
670
+ it('keeps every sidebar injection spot inside a self-collapsing wrapper', async () => {
671
+ const { container } = renderWithProviders(
672
+ <AppShell email="demo@example.com" groups={groups}>
673
+ <div>Child content</div>
674
+ </AppShell>,
675
+ { dict },
676
+ )
677
+
678
+ await waitFor(() => {
679
+ expect(screen.getByText('Users List')).toBeInTheDocument()
680
+ })
681
+
682
+ const sidebarSpotIds = ['backend:sidebar:top', 'backend:sidebar:nav', 'backend:sidebar:nav:footer', 'backend:sidebar:footer']
683
+ for (const spotId of sidebarSpotIds) {
684
+ const spot = container.querySelector(`[data-testid="injection-spot:${spotId}"]`)
685
+ expect(spot).not.toBeNull()
686
+ expect(spot!.closest('[data-sidebar-injection-wrapper="true"]')).not.toBeNull()
687
+ }
688
+ })
689
+
690
+ it('renders group separators on the group wrapper itself, skipping the last visible group', async () => {
691
+ const { container } = renderWithProviders(
692
+ <AppShell email="demo@example.com" groups={multiGroups}>
693
+ <div>Child content</div>
694
+ </AppShell>,
695
+ { dict },
696
+ )
697
+
698
+ await waitFor(() => {
699
+ expect(screen.getByText('Orders')).toBeInTheDocument()
700
+ })
701
+
702
+ const sidebar = screen.getByTestId('sidebar')
703
+ const groupWrappers = Array.from(sidebar.querySelectorAll('[data-sidebar-group="true"]'))
704
+ expect(groupWrappers).toHaveLength(multiGroups.length)
705
+ groupWrappers.forEach((wrapper, index) => {
706
+ const isLast = index === groupWrappers.length - 1
707
+ expect(wrapper).toHaveClass(isLast ? 'px-3' : 'border-b')
708
+ expect(wrapper.classList.contains('border-b')).toBe(!isLast)
709
+ expect(wrapper.classList.contains('pb-2')).toBe(!isLast)
710
+ })
711
+
712
+ // The old standalone bleeding separator elements must not come back — they were what
713
+ // forced the scroll container's negative-margin bleed in the first place.
714
+ expect(container.querySelectorAll('[data-sidebar-scroll="true"] div.border-t')).toHaveLength(0)
715
+ })
716
+
717
+ it('pads sidebar content blocks instead of bleeding the scroll container out of the aside', async () => {
718
+ const { container } = renderWithProviders(
719
+ <AppShell email="demo@example.com" groups={groups}>
720
+ <div>Child content</div>
721
+ </AppShell>,
722
+ { dict },
723
+ )
724
+
725
+ await waitFor(() => {
726
+ expect(screen.getByText('Users List')).toBeInTheDocument()
727
+ })
728
+
729
+ const scrollContainers = Array.from(container.querySelectorAll('[data-sidebar-scroll="true"]'))
730
+ expect(scrollContainers.length).toBeGreaterThan(0)
731
+ for (const scroller of scrollContainers) {
732
+ expect(scroller).toHaveClass('overflow-x-hidden')
733
+ for (const className of Array.from(scroller.classList)) {
734
+ expect(className.startsWith('-ml-')).toBe(false)
735
+ expect(className.startsWith('-mr-')).toBe(false)
736
+ }
737
+ }
738
+ })
739
+ })
740
+
580
741
  describe('two-level sidebar (settings/profile mode)', () => {
581
742
  it('renders main + section sidebars side-by-side when on a settings path', async () => {
582
743
  mockPathname = '/backend/entities/user'