@igstack/app-catalog-frontend-core 6.6.0 → 6.7.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 (139) hide show
  1. package/dist/esm/App.d.ts +29 -3
  2. package/dist/esm/App.js +4 -2
  3. package/dist/esm/App.js.map +1 -1
  4. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
  5. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
  6. package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
  7. package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
  8. package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
  9. package/dist/esm/appPropsFactory.d.ts +19 -1
  10. package/dist/esm/appPropsFactory.js +4 -3
  11. package/dist/esm/appPropsFactory.js.map +1 -1
  12. package/dist/esm/index.d.ts +15 -2
  13. package/dist/esm/index.js +5 -0
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/internal.d.ts +7 -1
  16. package/dist/esm/internal.js +0 -2
  17. package/dist/esm/internal.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
  19. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
  20. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
  22. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  23. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +39 -17
  24. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  25. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
  26. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
  28. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  29. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
  30. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
  31. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  32. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
  33. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
  34. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  35. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
  36. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  37. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
  38. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
  39. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  40. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
  41. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  42. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  44. package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
  45. package/dist/esm/modules/auth/AuthContext.js +5 -0
  46. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  47. package/dist/esm/modules/auth/index.d.ts +1 -1
  48. package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
  49. package/dist/esm/modules/dismiss/DismissStack.js +82 -0
  50. package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
  51. package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
  52. package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
  53. package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
  54. package/dist/esm/modules/extensions/factories.d.ts +18 -0
  55. package/dist/esm/modules/extensions/factories.js +12 -0
  56. package/dist/esm/modules/extensions/factories.js.map +1 -0
  57. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
  58. package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
  59. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
  60. package/dist/esm/modules/extensions/index.d.ts +79 -0
  61. package/dist/esm/modules/extensions/index.js +26 -0
  62. package/dist/esm/modules/extensions/index.js.map +1 -0
  63. package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
  64. package/dist/esm/modules/extensions/makeSlot.js +62 -0
  65. package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
  66. package/dist/esm/modules/extensions/types.d.ts +46 -0
  67. package/dist/esm/modules/gallery/Gallery.js +6 -14
  68. package/dist/esm/modules/gallery/Gallery.js.map +1 -1
  69. package/dist/esm/routeTree.gen.d.ts +34 -34
  70. package/dist/esm/routeTree.gen.js +14 -14
  71. package/dist/esm/routeTree.gen.js.map +1 -1
  72. package/dist/esm/router.d.ts +23 -0
  73. package/dist/esm/router.js +5 -0
  74. package/dist/esm/router.js.map +1 -0
  75. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  76. package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
  77. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
  78. package/dist/esm/routes/_layout/index.d.ts +1 -1
  79. package/dist/esm/routes/_layout/login.d.ts +1 -1
  80. package/dist/esm/routes/_layout/mcp.d.ts +1 -1
  81. package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
  82. package/dist/esm/routes/_layout.d.ts +1 -1
  83. package/dist/esm/routes/_layout.js +13 -2
  84. package/dist/esm/routes/_layout.js.map +1 -1
  85. package/dist/esm/routes/auth.callback.d.ts +1 -1
  86. package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
  87. package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
  88. package/dist/esm/ui/components/header/Header.js +0 -1
  89. package/dist/esm/ui/components/header/Header.js.map +1 -1
  90. package/dist/esm/ui/dialog.d.ts +1 -1
  91. package/dist/esm/ui/dialog.js +9 -0
  92. package/dist/esm/ui/dialog.js.map +1 -1
  93. package/dist/esm/ui/table.d.ts +25 -1
  94. package/dist/esm/ui/table.js +20 -3
  95. package/dist/esm/ui/table.js.map +1 -1
  96. package/dist/esm/util/createAcRouter.d.ts +1 -1
  97. package/package.json +19 -11
  98. package/src/App.tsx +34 -3
  99. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
  100. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
  101. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
  102. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
  103. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
  104. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
  105. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
  106. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
  107. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
  108. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
  109. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
  110. package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
  111. package/src/__tests__/publicEntryResolves.test.ts +63 -0
  112. package/src/appPropsFactory.ts +23 -3
  113. package/src/index.tsx +32 -2
  114. package/src/internal.ts +7 -1
  115. package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
  116. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
  117. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +59 -18
  118. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
  119. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
  120. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
  121. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
  122. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
  123. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
  124. package/src/modules/auth/AuthContext.tsx +15 -0
  125. package/src/modules/auth/index.ts +1 -0
  126. package/src/modules/dismiss/DismissStack.tsx +224 -0
  127. package/src/modules/extensions/ExtensionsContext.tsx +36 -0
  128. package/src/modules/extensions/factories.ts +19 -0
  129. package/src/modules/extensions/features/resourceDetail.ts +74 -0
  130. package/src/modules/extensions/index.ts +121 -0
  131. package/src/modules/extensions/makeSlot.tsx +123 -0
  132. package/src/modules/extensions/types.ts +51 -0
  133. package/src/modules/gallery/Gallery.tsx +13 -17
  134. package/src/routeTree.gen.ts +31 -31
  135. package/src/router.ts +23 -0
  136. package/src/routes/_layout.tsx +51 -1
  137. package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
  138. package/src/ui/dialog.tsx +26 -0
  139. package/src/ui/table.tsx +46 -3
@@ -0,0 +1,123 @@
1
+ import { Suspense } from 'react'
2
+ import { ErrorBoundary } from 'react-error-boundary'
3
+ import type { ReactNode } from 'react'
4
+ import { useExtensions } from './ExtensionsContext'
5
+ import type { OpaqueRenderer } from './types'
6
+
7
+ /**
8
+ * Reported when a plugin's render throws.
9
+ *
10
+ * Deliberately not swallowed. With a `null` fallback and no report, a crashing
11
+ * plugin is indistinguishable from an unregistered one — the button is simply
12
+ * absent, in production and in tests alike. Routing it through `console.error`
13
+ * is what makes `getGlobalError()` in the test kit able to assert it.
14
+ */
15
+ function reportSlotError(plugin: string, slot: string, error: unknown): void {
16
+ console.error(`[plugin:${plugin}] slot "${slot}" failed to render`, error)
17
+ }
18
+
19
+ const call = (fn: OpaqueRenderer, props: unknown): ReactNode =>
20
+ (fn as (p: unknown) => ReactNode)(props)
21
+
22
+ /**
23
+ * Invokes a plugin's handler from inside its own component.
24
+ *
25
+ * Not an inline `{call(render, props)}`: that expression is evaluated while the
26
+ * surrounding `Slot` renders, which is BEFORE React mounts the ErrorBoundary
27
+ * around it. A boundary only catches errors thrown by its descendants, so an
28
+ * inline call escaped the boundary entirely and took the host down with it.
29
+ * Putting the invocation in a child component is what moves the throw inside.
30
+ */
31
+ function Contribution({
32
+ render,
33
+ props,
34
+ }: {
35
+ render: OpaqueRenderer
36
+ props: unknown
37
+ }): ReactNode {
38
+ return call(render, props)
39
+ }
40
+
41
+ /**
42
+ * Builds the component the core renders at one leaf slot.
43
+ *
44
+ * Every registered plugin contributing to this slot renders, in array order, so
45
+ * two plugins can occupy the same point. Each is isolated: one plugin throwing
46
+ * removes only its own node.
47
+ *
48
+ * Each contribution also gets its own `Suspense`. Without it a plugin that
49
+ * suspends — any `useSuspenseQuery`, any lazy import — bubbles to the nearest
50
+ * boundary above, which for the detail card is `AppCatalogPage`'s
51
+ * `<Suspense fallback={null}>`; the whole card would then vanish until the
52
+ * plugin's data arrived.
53
+ */
54
+ export function makeSlot<TProps>(slotName: string) {
55
+ function Slot(props: TProps): ReactNode {
56
+ const plugins = useExtensions()
57
+
58
+ return plugins.map((plugin) => {
59
+ const render = plugin.slots?.[slotName]
60
+ if (!render) return null
61
+ return (
62
+ <ErrorBoundary
63
+ key={plugin.name}
64
+ fallbackRender={() => null}
65
+ onError={(error) => reportSlotError(plugin.name, slotName, error)}
66
+ >
67
+ <Suspense fallback={null}>
68
+ <Contribution render={render} props={props} />
69
+ </Suspense>
70
+ </ErrorBoundary>
71
+ )
72
+ })
73
+ }
74
+ Slot.displayName = `Slot(${slotName})`
75
+ return Slot
76
+ }
77
+
78
+ /**
79
+ * Builds the component the core renders *around* a subtree.
80
+ *
81
+ * Folded right-to-left so the FIRST plugin in the array ends up outermost, which
82
+ * makes nesting order readable from the array itself.
83
+ *
84
+ * Note both fallbacks differ from a leaf slot's: they are the accumulated
85
+ * children, never `null`. A wrapper only decorates — usually by providing
86
+ * context — so neither a broken one nor a suspending one may blank the core
87
+ * subtree it was wrapping.
88
+ *
89
+ * The `Suspense` matters as much as the boundary. Without it a wrapper that
90
+ * suspends bubbles to the nearest boundary above, which for the detail card is
91
+ * `AppCatalogPage`'s `<Suspense fallback={null}>` — so one plugin fetching
92
+ * something would make the entire card disappear until it resolved. Falling
93
+ * back to `acc` instead renders the core subtree immediately, just without the
94
+ * decoration, and it arrives when the plugin is ready.
95
+ *
96
+ * A consequence worth stating: while suspended, descendants see no context from
97
+ * this wrapper. A wrapper whose children cannot cope with that should not
98
+ * suspend — it should render children and fetch inside its own subtree.
99
+ */
100
+ export function makeWrapper<TProps>(wrapperName: string) {
101
+ function Wrapper(props: TProps & { children: ReactNode }): ReactNode {
102
+ const plugins = useExtensions()
103
+ const { children, ...rest } = props
104
+
105
+ return plugins.reduceRight<ReactNode>((acc, plugin) => {
106
+ const render = plugin.wrappers?.[wrapperName]
107
+ if (!render) return acc
108
+ return (
109
+ <ErrorBoundary
110
+ key={plugin.name}
111
+ fallbackRender={() => acc}
112
+ onError={(error) => reportSlotError(plugin.name, wrapperName, error)}
113
+ >
114
+ <Suspense fallback={acc}>
115
+ <Contribution render={render} props={{ ...rest, children: acc }} />
116
+ </Suspense>
117
+ </ErrorBoundary>
118
+ )
119
+ }, children)
120
+ }
121
+ Wrapper.displayName = `SlotWrapper(${wrapperName})`
122
+ return Wrapper
123
+ }
@@ -0,0 +1,51 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * The signed-in visitor as a plugin sees them.
5
+ *
6
+ * Structural on purpose. Core's own `User` (see `modules/auth/AuthContext.tsx`)
7
+ * is not exported, and a plugin has no business depending on core's auth
8
+ * internals: it needs an identity and an admin flag, nothing more. Widening this
9
+ * is a deliberate API decision, not a convenience.
10
+ */
11
+ export interface PluginUser {
12
+ email?: string | null
13
+ isAdmin: boolean
14
+ }
15
+
16
+ /**
17
+ * A renderer whose props are deliberately unknowable.
18
+ *
19
+ * This exists only to break a type cycle: the context has to hold plugins, a
20
+ * plugin's slot map is keyed by the composed `SlotSpec`, and `SlotSpec` is
21
+ * composed from the feature files that call `slotFactory` — which would need the
22
+ * context back. `never` in parameter position accepts every function regardless
23
+ * of what it takes (parameters are contravariant under `strictFunctionTypes`), so
24
+ * the registration stays assignable while the PUBLIC types in `index.ts` remain
25
+ * exact. `makeSlot` re-narrows before calling.
26
+ */
27
+ export type OpaqueRenderer = (props: never) => ReactNode
28
+
29
+ /**
30
+ * What the context stores. Consumers never build this by hand — they build an
31
+ * `AcPlugin` (see `index.ts`), which is assignable to it.
32
+ */
33
+ export interface PluginRegistration {
34
+ name: string
35
+ slots?: Record<string, OpaqueRenderer | undefined>
36
+ wrappers?: Record<string, OpaqueRenderer | undefined>
37
+ }
38
+
39
+ /**
40
+ * Compile error naming any slot id two features both claim.
41
+ *
42
+ * `SlotSpec` is an intersection of the per-feature specs, and an intersection
43
+ * accepts anything: two features declaring one id would silently intersect their
44
+ * payloads into `A & B` — a slot nothing can satisfy. `extends` would error, but
45
+ * only with a generic "incompatibly declared" message. This puts the offending
46
+ * id in the error text.
47
+ */
48
+ export type AssertDisjoint<TLeft, TRight> =
49
+ Extract<keyof TLeft, keyof TRight> extends never
50
+ ? true
51
+ : { DUPLICATE_SLOT_ID: Extract<keyof TLeft, keyof TRight> }
@@ -3,6 +3,7 @@ import useEmblaCarousel from 'embla-carousel-react'
3
3
  import { ChevronLeft, ChevronRight, ImageOff, X } from 'lucide-react'
4
4
  import { clamp } from 'radashi'
5
5
  import { useHotkeys } from 'react-hotkeys-hook'
6
+ import { useDismissable } from '~/modules/dismiss/DismissStack'
6
7
 
7
8
  import { Button } from '~/ui/button'
8
9
  import { Spinner } from '~/ui/spinner'
@@ -129,23 +130,17 @@ export function Gallery({
129
130
  },
130
131
  [scrollNext, images.length],
131
132
  )
132
- // Intercept Escape in fullscreen — must stopPropagation before Radix Dialog sees it
133
- useEffect(() => {
134
- if (!isFullscreen) return
135
-
136
- const handleEscape = (e: KeyboardEvent) => {
137
- if (e.key === 'Escape') {
138
- e.stopPropagation()
139
- e.preventDefault()
140
- setFullscreen(false)
141
- }
142
- }
143
-
144
- document.addEventListener('keydown', handleEscape, true)
145
- return () => {
146
- document.removeEventListener('keydown', handleEscape, true)
147
- }
148
- }, [isFullscreen, setFullscreen])
133
+ // Escape leaves fullscreen rather than closing the gallery.
134
+ //
135
+ // This used to be a capture-phase document listener whose whole purpose was
136
+ // to beat Radix to the event, with the dialog above separately cancelling
137
+ // Radix's close by reading this component's fullscreen state through a ref.
138
+ // The stack makes that unnecessary: the fullscreen view is rendered after
139
+ // the dialog that contains it, so it is simply the top layer while it is on.
140
+ const fullscreenRef = useRef<HTMLDivElement>(null)
141
+ useDismissable(fullscreenRef, () => setFullscreen(false), {
142
+ enabled: isFullscreen,
143
+ })
149
144
 
150
145
  // Mouse wheel navigation with debouncing
151
146
  useEffect(() => {
@@ -209,6 +204,7 @@ export function Gallery({
209
204
  const imageState = imageStates[currentIndex]
210
205
  return (
211
206
  <div
207
+ ref={fullscreenRef}
212
208
  className="fixed inset-0 z-50 bg-background"
213
209
  role="dialog"
214
210
  aria-label="Fullscreen view"
@@ -11,10 +11,10 @@
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as LayoutRouteImport } from './routes/_layout'
13
13
  import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
14
- import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
15
- import { Route as LayoutServiceDesksRouteImport } from './routes/_layout/service-desks'
16
- import { Route as LayoutMcpRouteImport } from './routes/_layout/mcp'
17
14
  import { Route as LayoutLoginRouteImport } from './routes/_layout/login'
15
+ import { Route as LayoutMcpRouteImport } from './routes/_layout/mcp'
16
+ import { Route as LayoutServiceDesksRouteImport } from './routes/_layout/service-desks'
17
+ import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
18
18
  import { Route as LayoutAppSlugRouteImport } from './routes/_layout/app.$slug'
19
19
  import { Route as LayoutCatalogAppsIndexRouteImport } from './routes/_layout/catalog.apps.index'
20
20
  import { Route as LayoutAppSlugSubSubSlugRouteImport } from './routes/_layout/app.$slug_.sub.$subSlug'
@@ -28,14 +28,9 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
28
28
  path: '/',
29
29
  getParentRoute: () => LayoutRoute,
30
30
  } as any)
31
- const AuthCallbackRoute = AuthCallbackRouteImport.update({
32
- id: '/auth/callback',
33
- path: '/auth/callback',
34
- getParentRoute: () => rootRouteImport,
35
- } as any)
36
- const LayoutServiceDesksRoute = LayoutServiceDesksRouteImport.update({
37
- id: '/service-desks',
38
- path: '/service-desks',
31
+ const LayoutLoginRoute = LayoutLoginRouteImport.update({
32
+ id: '/login',
33
+ path: '/login',
39
34
  getParentRoute: () => LayoutRoute,
40
35
  } as any)
41
36
  const LayoutMcpRoute = LayoutMcpRouteImport.update({
@@ -43,11 +38,16 @@ const LayoutMcpRoute = LayoutMcpRouteImport.update({
43
38
  path: '/mcp',
44
39
  getParentRoute: () => LayoutRoute,
45
40
  } as any)
46
- const LayoutLoginRoute = LayoutLoginRouteImport.update({
47
- id: '/login',
48
- path: '/login',
41
+ const LayoutServiceDesksRoute = LayoutServiceDesksRouteImport.update({
42
+ id: '/service-desks',
43
+ path: '/service-desks',
49
44
  getParentRoute: () => LayoutRoute,
50
45
  } as any)
46
+ const AuthCallbackRoute = AuthCallbackRouteImport.update({
47
+ id: '/auth/callback',
48
+ path: '/auth/callback',
49
+ getParentRoute: () => rootRouteImport,
50
+ } as any)
51
51
  const LayoutAppSlugRoute = LayoutAppSlugRouteImport.update({
52
52
  id: '/app/$slug',
53
53
  path: '/app/$slug',
@@ -151,18 +151,11 @@ declare module '@tanstack/react-router' {
151
151
  preLoaderRoute: typeof LayoutIndexRouteImport
152
152
  parentRoute: typeof LayoutRoute
153
153
  }
154
- '/auth/callback': {
155
- id: '/auth/callback'
156
- path: '/auth/callback'
157
- fullPath: '/auth/callback'
158
- preLoaderRoute: typeof AuthCallbackRouteImport
159
- parentRoute: typeof rootRouteImport
160
- }
161
- '/_layout/service-desks': {
162
- id: '/_layout/service-desks'
163
- path: '/service-desks'
164
- fullPath: '/service-desks'
165
- preLoaderRoute: typeof LayoutServiceDesksRouteImport
154
+ '/_layout/login': {
155
+ id: '/_layout/login'
156
+ path: '/login'
157
+ fullPath: '/login'
158
+ preLoaderRoute: typeof LayoutLoginRouteImport
166
159
  parentRoute: typeof LayoutRoute
167
160
  }
168
161
  '/_layout/mcp': {
@@ -172,13 +165,20 @@ declare module '@tanstack/react-router' {
172
165
  preLoaderRoute: typeof LayoutMcpRouteImport
173
166
  parentRoute: typeof LayoutRoute
174
167
  }
175
- '/_layout/login': {
176
- id: '/_layout/login'
177
- path: '/login'
178
- fullPath: '/login'
179
- preLoaderRoute: typeof LayoutLoginRouteImport
168
+ '/_layout/service-desks': {
169
+ id: '/_layout/service-desks'
170
+ path: '/service-desks'
171
+ fullPath: '/service-desks'
172
+ preLoaderRoute: typeof LayoutServiceDesksRouteImport
180
173
  parentRoute: typeof LayoutRoute
181
174
  }
175
+ '/auth/callback': {
176
+ id: '/auth/callback'
177
+ path: '/auth/callback'
178
+ fullPath: '/auth/callback'
179
+ preLoaderRoute: typeof AuthCallbackRouteImport
180
+ parentRoute: typeof rootRouteImport
181
+ }
182
182
  '/_layout/app/$slug': {
183
183
  id: '/_layout/app/$slug'
184
184
  path: '/app/$slug'
package/src/router.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The core's own router, on a subpath of its own — a deliberate quarantine.
3
+ *
4
+ * `createAcRouter` carries `declare module '@tanstack/react-router'` for both
5
+ * `Register` and (via `routeTree.gen.ts`) `FileRoutesByPath`. TypeScript
6
+ * collects module augmentations when it builds the PROGRAM, and `import type`
7
+ * still resolves the specifier — so there is no way to reference anything from
8
+ * that file without admitting its augmentations. Even `await import()` does it;
9
+ * type elision is emit-only.
10
+ *
11
+ * Two declarations of `Register.router` in one program is a hard `TS2717`, and
12
+ * two of `FileRoutesByPath` collapses `createFileRoute` to `path?: never`. So a
13
+ * deployment that composes its own route tree — core routes plus its own, and
14
+ * therefore its own augmentation — must never end up with this file in its
15
+ * program. Keeping it off the main entry's import graph is what makes that
16
+ * possible, and this subpath is the only published door to it.
17
+ *
18
+ * Import it if you use the core's route tree unchanged: that is the example app
19
+ * and the core's own test suites, each a single-`Register` program. Do NOT
20
+ * import it from a deployment that declares its own.
21
+ */
22
+ export { createAcRouter } from './util/createAcRouter'
23
+ export type { AcRouterInitParams } from './types/types'
@@ -10,6 +10,36 @@ import { Route as AppDetailRoute } from './_layout/app.$slug'
10
10
  import { Route as SubResourceDetailRoute } from './_layout/app.$slug_.sub.$subSlug'
11
11
  import { Route as CatalogIndexRoute } from './_layout/index'
12
12
 
13
+ /**
14
+ * The opt-in a deployment's own route sets to get the catalog layout:
15
+ *
16
+ * createFileRoute('/_layout/app/$slug_/whatever')({
17
+ * staticData: { acCatalogLayout: true },
18
+ * component: MyRoute,
19
+ * })
20
+ *
21
+ * Narrowed rather than asserted: `staticData` is a loose bag that a consumer
22
+ * fills, so an unexpected shape must read as "not opted in" instead of
23
+ * throwing inside a router-state selector.
24
+ */
25
+ function wantsCatalogLayout(staticData: unknown): boolean {
26
+ return (
27
+ typeof staticData === 'object' &&
28
+ staticData !== null &&
29
+ 'acCatalogLayout' in staticData &&
30
+ staticData.acCatalogLayout === true
31
+ )
32
+ }
33
+
34
+ function hasSlug(params: unknown): params is { slug: string } {
35
+ return (
36
+ typeof params === 'object' &&
37
+ params !== null &&
38
+ 'slug' in params &&
39
+ typeof params.slug === 'string'
40
+ )
41
+ }
42
+
13
43
  export const Route = createFileRoute('/_layout')({
14
44
  component: LayoutComponent,
15
45
  })
@@ -35,12 +65,32 @@ function LayoutComponent() {
35
65
  const params = (detailMatch ?? subMatch)?.params as
36
66
  | { slug?: string; subSlug?: string }
37
67
  | undefined
68
+ // A deployment composing its own route tree can put a route inside this
69
+ // layout, and it will not be one of the ids above — so without an opt-in
70
+ // it silently renders outside AppCatalogLayout with no providers, and
71
+ // the first hook that needs the catalog throws. `staticData` is the
72
+ // opt-in: the route declares that it wants the catalog layout, and the
73
+ // core needs to know nothing about it.
74
+ const optedIn = s.matches.some(
75
+ (m) => wantsCatalogLayout(m.staticData) === true,
76
+ )
77
+ // The slug from whichever match carries one, so a deployment's own route
78
+ // under /app/$slug/... opens the card it belongs to rather than dropping
79
+ // the user on the catalog index.
80
+ const slugMatch = [...s.matches].reverse().find((m) => hasSlug(m.params))
81
+ // One narrowing is enough: the predicate on `find` already guarantees the
82
+ // match it returned carries a string slug.
83
+ const consumerSlug = hasSlug(slugMatch?.params)
84
+ ? slugMatch.params.slug
85
+ : undefined
86
+
38
87
  return {
39
88
  isCatalogRoute:
40
89
  detailMatch !== undefined ||
41
90
  subMatch !== undefined ||
91
+ optedIn ||
42
92
  s.matches.some((m) => m.routeId === CatalogIndexRoute.id),
43
- selectedSlug: params?.slug,
93
+ selectedSlug: params?.slug ?? consumerSlug,
44
94
  selectedSubSlug: params?.subSlug,
45
95
  }
46
96
  },
@@ -97,7 +97,27 @@ export function Treatment({ error, reset }: ErrorComponentProps) {
97
97
  )
98
98
  }
99
99
 
100
+ /**
101
+ * What a thrown value can be relied on to have.
102
+ *
103
+ * `ErrorComponentProps.error` is `unknown`, and rightly so — `throw` accepts
104
+ * any value, so a route can be handed a string or a plain object. Narrowing
105
+ * here rather than asserting `Error` means a non-Error throw renders a useful
106
+ * message instead of "Cannot read properties of undefined".
107
+ */
108
+ function describeThrown(error: unknown): {
109
+ message: string
110
+ stack: string | undefined
111
+ } {
112
+ if (error instanceof Error) {
113
+ return { message: error.message, stack: error.stack }
114
+ }
115
+ return { message: String(error), stack: undefined }
116
+ }
117
+
100
118
  export function DefaultErrorComponent({ error, reset }: ErrorComponentProps) {
119
+ const { message, stack } = describeThrown(error)
120
+
101
121
  return (
102
122
  <BaseErrorPage>
103
123
  <Empty role="alert">
@@ -107,7 +127,7 @@ export function DefaultErrorComponent({ error, reset }: ErrorComponentProps) {
107
127
  </EmptyMedia>
108
128
  <EmptyTitle>Ooops!</EmptyTitle>
109
129
  <EmptyDescription>
110
- Error inside app-catalog occured: {<i>{error.message}</i>}
130
+ Error inside app-catalog occured: {<i>{message}</i>}
111
131
  </EmptyDescription>
112
132
  </EmptyHeader>
113
133
  <EmptyContent>
@@ -118,9 +138,7 @@ export function DefaultErrorComponent({ error, reset }: ErrorComponentProps) {
118
138
  'mt-8 text-center max-w-[90vw] max-h-[80vh] overflow-auto'
119
139
  }
120
140
  >
121
- <pre className={'text-left mt-8 text-sm'}>
122
- {<i>{error.stack}</i>}
123
- </pre>
141
+ <pre className={'text-left mt-8 text-sm'}>{<i>{stack}</i>}</pre>
124
142
  </div>
125
143
  </EmptyContent>
126
144
  </Empty>
package/src/ui/dialog.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as React from 'react'
2
2
  import * as DialogPrimitive from '@radix-ui/react-dialog'
3
+ import { useDismissStack } from '~/modules/dismiss/DismissStack'
3
4
  import { XIcon } from 'lucide-react'
4
5
 
5
6
  import { cn } from '~/lib/utils'
@@ -48,15 +49,40 @@ function DialogContent({
48
49
  className,
49
50
  children,
50
51
  showCloseButton = true,
52
+ onEscapeKeyDown,
51
53
  ...props
52
54
  }: React.ComponentProps<typeof DialogPrimitive.Content> & {
53
55
  showCloseButton?: boolean
54
56
  }) {
57
+ const stack = useDismissStack()
58
+
59
+ /**
60
+ * Bridges Radix's Escape handling into the dismiss stack.
61
+ *
62
+ * Radix listens for Escape with `capture: true`, so it runs before the
63
+ * stack's own document listener and before every component handler. Left
64
+ * alone, a Radix dialog therefore closes itself even when something else is
65
+ * on top of it — which is the bug this whole module exists to fix, just
66
+ * from the other direction.
67
+ *
68
+ * So: ask the stack first. If it had a layer to dismiss, that layer was the
69
+ * topmost one and Radix must stand down, which `preventDefault` tells it.
70
+ * If the stack was empty, nothing registered an opinion and Radix closing
71
+ * this dialog is exactly right — which keeps every existing dialog working
72
+ * whether or not anyone has wired it into the stack.
73
+ */
74
+ const handleEscapeKeyDown = (event: KeyboardEvent) => {
75
+ onEscapeKeyDown?.(event)
76
+ if (event.defaultPrevented) return
77
+ if (stack.dismissTop()) event.preventDefault()
78
+ }
79
+
55
80
  return (
56
81
  <DialogPortal data-slot="dialog-portal">
57
82
  <DialogOverlay />
58
83
  <DialogPrimitive.Content
59
84
  data-slot="dialog-content"
85
+ onEscapeKeyDown={handleEscapeKeyDown}
60
86
  className={cn(
61
87
  'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
62
88
  className,
package/src/ui/table.tsx CHANGED
@@ -2,15 +2,58 @@ import * as React from 'react'
2
2
 
3
3
  import { cn } from '~/lib/utils'
4
4
 
5
- function Table({ className, ...props }: React.ComponentProps<'table'>) {
5
+ interface TableProps extends React.ComponentProps<'table'> {
6
+ /**
7
+ * Keep the header visible while the body scrolls.
8
+ *
9
+ * The scroll has to be on the container BELOW, not on an ancestor, and that is
10
+ * the whole difficulty: this container sets `overflow-x: auto`, which per spec
11
+ * makes `overflow-y: visible` compute to `auto` as well. So it is already a
12
+ * scrollport on both axes, and `position: sticky` on the header resolves
13
+ * against it rather than against whatever ancestor has the height limit.
14
+ * Putting the height limit anywhere above leaves the header pinned to an
15
+ * element that never scrolls vertically — it scrolls away exactly as if
16
+ * nothing had been set.
17
+ *
18
+ * Pass the max height as `className` (e.g. `max-h-[400px]`); it lands on this
19
+ * container, which then both clips and scrolls.
20
+ */
21
+ stickyHeader?: boolean
22
+ /**
23
+ * The scrolling container, for a caller that virtualizes the body: the
24
+ * scrollport is this element, not the `<table>`, so a ref on the table
25
+ * would measure the wrong thing.
26
+ */
27
+ containerRef?: React.Ref<HTMLDivElement>
28
+ }
29
+
30
+ function Table({
31
+ className,
32
+ stickyHeader,
33
+ containerRef,
34
+ ...props
35
+ }: TableProps) {
6
36
  return (
7
37
  <div
38
+ ref={containerRef}
8
39
  data-slot="table-container"
9
- className="relative w-full overflow-x-auto"
40
+ className={cn(
41
+ 'relative w-full overflow-x-auto',
42
+ stickyHeader && 'overflow-y-auto',
43
+ stickyHeader && className,
44
+ )}
10
45
  >
11
46
  <table
12
47
  data-slot="table"
13
- className={cn('w-full caption-bottom text-sm', className)}
48
+ className={cn(
49
+ 'w-full caption-bottom text-sm',
50
+ // The header's own bottom border does not render reliably once the
51
+ // row is sticky, so the separating line comes from an inset shadow on
52
+ // the cells instead. Opaque background, or rows show through it.
53
+ stickyHeader &&
54
+ '[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 [&>thead>tr]:border-b-0 [&>thead_th]:bg-background [&>thead_th]:shadow-[inset_0_-1px_0_var(--border)]',
55
+ !stickyHeader && className,
56
+ )}
14
57
  {...props}
15
58
  />
16
59
  </div>