@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
@@ -31,6 +31,7 @@ import { formatRelativeTime } from '../../utils/formatRelativeTime'
31
31
  import { TierVariantsSection } from '../components/TierVariantsSection'
32
32
  import { SubResourcesSection } from '../components/SubResourcesSection'
33
33
  import { getChildResources } from '../../utils/resolveHelpers'
34
+ import { ResourceDetailAccessActions } from '~/modules/extensions'
34
35
  import { FeedbackSection } from './FeedbackSection'
35
36
  import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
36
37
  import { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'
@@ -116,11 +117,9 @@ function AppScreenshot({ app }: { app: Resource }) {
116
117
  export function AppDetails({
117
118
  app,
118
119
  onAppClick,
119
- onClosePanel,
120
120
  }: {
121
121
  app: Resource
122
122
  onAppClick?: (app: Resource) => void
123
- onClosePanel: () => void
124
123
  }) {
125
124
  const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)
126
125
  const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)
@@ -175,6 +174,31 @@ export function AppDetails({
175
174
  [allResources, app.slug],
176
175
  )
177
176
 
177
+ // `app` is whatever resource the card shows, a nested one included, so the
178
+ // parent has to be looked up rather than assumed absent. A plugin uses its
179
+ // presence to tell a sub-resource view from a top-level one.
180
+ const parent = React.useMemo(
181
+ () =>
182
+ app.parentSlug
183
+ ? allResources.find((r) => r.slug === app.parentSlug)
184
+ : undefined,
185
+ [allResources, app.parentSlug],
186
+ )
187
+
188
+ // What a plugin slot in the band receives. Memoised so a plugin that
189
+ // memoises on its props is not defeated by a new object every render.
190
+ const accessActionsCtx = React.useMemo(
191
+ () => ({
192
+ resource: app,
193
+ parent,
194
+ subResources: parent
195
+ ? getChildResources(allResources, parent.slug)
196
+ : children,
197
+ user: user ? { email: user.email, isAdmin } : null,
198
+ }),
199
+ [app, parent, allResources, children, user, isAdmin],
200
+ )
201
+
178
202
  // Enter: open screenshot gallery
179
203
  useHotkeys(
180
204
  'enter',
@@ -198,21 +222,6 @@ export function AppDetails({
198
222
  [app, isGalleryOpen],
199
223
  )
200
224
 
201
- // Esc: close the details panel (only when gallery is NOT open)
202
- //
203
- // From inside a text field too: the Quick Jump field holds the caret from the
204
- // moment the card opens, so the default "ignore form tags" left the card with
205
- // no keyboard way out. A field that wants Esc for itself stops the event —
206
- // InlineEditableField does, so Esc there cancels the edit and nothing more.
207
- useHotkeys(
208
- 'escape',
209
- () => {
210
- onClosePanel()
211
- },
212
- { enabled: !isGalleryOpen, enableOnFormTags: ['input'] },
213
- [isGalleryOpen, onClosePanel],
214
- )
215
-
216
225
  const handleScreenshotClick = (index: number) => {
217
226
  setGalleryInitialIndex(index)
218
227
  setIsGalleryOpen(true)
@@ -346,7 +355,9 @@ export function AppDetails({
346
355
  <SubResourcesSection
347
356
  subResources={children}
348
357
  parentSlug={app.slug}
358
+ parent={app}
349
359
  initialSearch={filterState.searchValue}
360
+ childrenLabel={app.childrenLabel}
350
361
  />
351
362
  </div>
352
363
  )}
@@ -650,15 +661,24 @@ export function AppDetails({
650
661
  card to find. Up here it is present on every tab, which is the whole
651
662
  reason the band does not scroll away.
652
663
  `mr-10` keeps it clear of the dialog's own close button at top-right. */}
653
- <Button
654
- type="button"
655
- size="sm"
656
- variant="outline"
657
- className="ml-auto mr-10"
658
- onClick={() => setComposeRequests((n) => n + 1)}
659
- >
660
- ✎ Suggest a change
661
- </Button>
664
+ {/* Plugin actions sit beside "Suggest a change", sharing its reasoning:
665
+ the band is mounted on every tab and never scrolls away. A slot in a
666
+ tab panel would be invisible until a second click, and one inside
667
+ SubResourceDetailPanel's `hasAnyAccess` gate would vanish for exactly
668
+ the resources that need help most.
669
+ Renders nothing when no plugin contributes, so the band keeps its
670
+ current shape in the open-source build. */}
671
+ <div className="ml-auto mr-10 flex items-center gap-2">
672
+ <ResourceDetailAccessActions {...accessActionsCtx} />
673
+ <Button
674
+ type="button"
675
+ size="sm"
676
+ variant="outline"
677
+ onClick={() => setComposeRequests((n) => n + 1)}
678
+ >
679
+ ✎ Suggest a change
680
+ </Button>
681
+ </div>
662
682
  </div>
663
683
 
664
684
  {app.nicknames && app.nicknames.length > 0 && (
@@ -116,6 +116,26 @@ export function DetailTabs({
116
116
  setUserChose(true)
117
117
  setUrlTab(requestTab)
118
118
  }, [requestTab, requestNonce, setUrlTab])
119
+ /**
120
+ * Releasing the force must not move the view.
121
+ *
122
+ * Arriving with `?sub=` forces the sub-resources tab, but only through
123
+ * `forcedTab` — the tab's own state is still the default. Clearing the
124
+ * selection drops `?sub=`, the force goes with it, and the strip fell back to
125
+ * that stale default: the reader was moved to Overview while looking at the
126
+ * table they had just cleared a filter on. So when the force is released
127
+ * before the user picked anything, the forced tab becomes the real choice.
128
+ *
129
+ * Adjusted during render rather than in an effect, the pattern React
130
+ * documents for responding to a prop change: an effect would paint one frame
131
+ * of Overview first, mounting that panel only to tear it down again.
132
+ */
133
+ const [lastForced, setLastForced] = React.useState(forcedTab)
134
+ if (forcedTab !== lastForced) {
135
+ setLastForced(forcedTab)
136
+ if (lastForced && !forcedTab && !userChose) setUrlTab(lastForced)
137
+ }
138
+
119
139
  const asked = userChose || !forcedTab ? urlTab : forcedTab
120
140
  const requested = RETIRED[asked] ?? asked
121
141
  const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')
@@ -68,6 +68,22 @@ export function AppCatalogPage({
68
68
  [resources],
69
69
  )
70
70
 
71
+ /**
72
+ * What the grid actually shows, memoized because its IDENTITY matters.
73
+ *
74
+ * Filtering inline in the JSX produced a new array on every render, and the
75
+ * grid keys four `useMemo`s off this prop (the slug index, "new this week",
76
+ * the browse list and its area grouping). A fresh identity invalidated all
77
+ * four on every keystroke — recomputed even while searching, when the
78
+ * discovery spine they feed is not on screen. The search box lives in a
79
+ * context above this component, so every keystroke re-renders here.
80
+ */
81
+ const visibleRootResources = useMemo(
82
+ () =>
83
+ rootResources.filter((a) => filterState.showDeprecated || !a.deprecated),
84
+ [rootResources, filterState.showDeprecated],
85
+ )
86
+
71
87
  // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level
72
88
  // (rootResources === 0). That fingerprints a frontend/backend-core version skew
73
89
  // or a stale service worker — NOT a normal empty search/filter (those still have
@@ -187,9 +203,7 @@ export function AppCatalogPage({
187
203
  // against the bottom edge or under the floating support bubble.
188
204
  <div className="flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24">
189
205
  <AppCatalogGrid
190
- apps={rootResources.filter(
191
- (a) => filterState.showDeprecated || !a.deprecated,
192
- )}
206
+ apps={visibleRootResources}
193
207
  // All resources incl. children — so search matches sub-resources
194
208
  // (e.g. a cloud account) and surfaces their parent.
195
209
  allResources={resources}
@@ -131,3 +131,18 @@ export function useUser() {
131
131
  const { user } = useAuth()
132
132
  return user
133
133
  }
134
+
135
+ /**
136
+ * The current user, or null when there is no `AuthProvider` above.
137
+ *
138
+ * `useUser()` throws without a provider, which is correct for UI that only ever
139
+ * renders inside the app but wrong for anything a test may mount bare. Several
140
+ * components are rendered bare on purpose (`AppDetailPanel`, `AppCatalogGrid`),
141
+ * so assembling a plugin slot payload with `useUser()` there turns every one of
142
+ * those tests red — "useAuth must be used within AuthProvider".
143
+ *
144
+ * Signed-out and no-provider are the same answer for a slot payload: null.
145
+ */
146
+ export function useOptionalUser() {
147
+ return use(AuthContext)?.user ?? null
148
+ }
@@ -4,6 +4,7 @@ export {
4
4
  useAuth,
5
5
  useIsAuthenticated,
6
6
  useUser,
7
+ useOptionalUser,
7
8
  } from './AuthContext'
8
9
  export { ProtectedContent, withProtection } from './ProtectedContent'
9
10
  export { LoginPage } from './ui/LoginPage'
@@ -0,0 +1,224 @@
1
+ import { createContext, use, useEffect, useMemo, useRef } from 'react'
2
+
3
+ /**
4
+ * One Escape key, one answer: dismiss the layer on top.
5
+ *
6
+ * Before this existed, Escape was handled in six independent places, and each
7
+ * one had to know about the others to avoid double-firing — the detail card
8
+ * bound Escape only `enabled: !isGalleryOpen`, the gallery installed its own
9
+ * capture-phase listener specifically to beat Radix to the event, and the
10
+ * screenshot dialog cancelled Radix's own Escape by reading the gallery's
11
+ * fullscreen state through a ref. Every new overlay had to learn that dance,
12
+ * and getting it wrong stayed invisible until someone pressed Escape in the
13
+ * wrong order. It was wrong: a dialog opened over the card had no say, so
14
+ * Escape closed the card out from under it and navigated away.
15
+ *
16
+ * A layer now says only "I am dismissable, and this element is me". The stack
17
+ * decides who Escape belongs to, and no layer needs to know another exists.
18
+ *
19
+ * "On top" is DOCUMENT ORDER, the last enabled layer in the document. Two
20
+ * cheaper keys were tried first and both broke:
21
+ *
22
+ * - Registration order. React runs a child's effects before its parent's, so
23
+ * an overlay nested inside another registers FIRST and the container looks
24
+ * newer: Escape in the screenshot viewer closed the card under it.
25
+ * - Activation time. On a cold deep link the dialog's route resolves before
26
+ * the catalog arrives, so the card it sits over mounts LATER and looks
27
+ * newer: Escape closed the card first, and the card took focus back off the
28
+ * dialog's first field.
29
+ *
30
+ * Document order has neither problem because it is what "on top" means on
31
+ * screen: a portal appends to the end of `<body>`, a child route renders after
32
+ * its parent's page, and a nested layer is a descendant — and a descendant
33
+ * follows its ancestor in document order. None of it depends on when anything
34
+ * mounted.
35
+ *
36
+ * `useDismissable` works with or without a provider above it, on purpose. A
37
+ * hook that silently did nothing outside a provider would be the same class of
38
+ * bug this module exists to delete — every render site, including tests and
39
+ * stories, would have to remember a wrapper, and forgetting it would look like
40
+ * working code with dead Escape handling. So an app-wide stack is the default
41
+ * and `DismissStackProvider` is only for scoping.
42
+ */
43
+
44
+ interface DismissEntry {
45
+ /** The layer's root element; its position in the document is its rank. */
46
+ layer: { readonly current: HTMLElement | null }
47
+ /** Called when Escape belongs to this layer. */
48
+ onDismiss: () => void
49
+ /**
50
+ * A layer can stay registered and opt out — a collapsed panel, a closed
51
+ * popover. Skipped when choosing the top rather than unregistering.
52
+ */
53
+ enabled: boolean
54
+ }
55
+
56
+ export interface DismissStackApi {
57
+ /** Adds an entry and returns its unregister function. */
58
+ register: (entry: DismissEntry) => () => void
59
+ /** Dismisses the top layer; reports whether there was one. */
60
+ dismissTop: () => boolean
61
+ /**
62
+ * The top layer's element, or null when nothing is open. For a layer that
63
+ * takes focus on mount: it should only do so while it is on top, or it
64
+ * steals the caret from whatever was opened over it.
65
+ */
66
+ topElement: () => HTMLElement | null
67
+ }
68
+
69
+ function isAfter(a: HTMLElement, b: HTMLElement): boolean {
70
+ // A descendant reports FOLLOWING as well as CONTAINED_BY, so this one test
71
+ // covers both "rendered later" and "nested inside".
72
+ return (b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
73
+ }
74
+
75
+ function createStack(): DismissStackApi {
76
+ const entries: DismissEntry[] = []
77
+
78
+ const top = (): DismissEntry | undefined => {
79
+ let best: DismissEntry | undefined
80
+ for (const entry of entries) {
81
+ const el = entry.layer.current
82
+ // Not in the document yet, or already gone: not something on screen.
83
+ if (!entry.enabled || !el?.isConnected) continue
84
+ const bestEl = best?.layer.current
85
+ if (!bestEl || isAfter(el, bestEl)) best = entry
86
+ }
87
+ return best
88
+ }
89
+
90
+ const dismissTop = () => {
91
+ const entry = top()
92
+ if (!entry) return false
93
+ entry.onDismiss()
94
+ return true
95
+ }
96
+
97
+ const topElement = () => top()?.layer.current ?? null
98
+
99
+ const onKeyDown = (event: KeyboardEvent) => {
100
+ if (event.key !== 'Escape') return
101
+ // Something closer to the event already dealt with it — a text field
102
+ // cancelling an edit, say. Honoured rather than overridden: the element the
103
+ // user is typing in is more specific than any layer containing it.
104
+ //
105
+ // This check is the reason the listener below is on the BUBBLE phase. In
106
+ // capture it would run before every component handler, so `defaultPrevented`
107
+ // would always be false and a field could never keep Escape for itself.
108
+ if (event.defaultPrevented) return
109
+ if (!dismissTop()) return
110
+ // Claim it, so a handler further out does not also act.
111
+ event.preventDefault()
112
+ event.stopPropagation()
113
+ }
114
+
115
+ const register = (entry: DismissEntry) => {
116
+ if (entries.length === 0) {
117
+ // Bubble, on the document: every component handler and React's own
118
+ // delegated handlers run first, so the innermost thing that wants Escape
119
+ // gets it. Radix is the one exception — it listens with `capture: true`,
120
+ // so it runs BEFORE this. `DialogContent` bridges that by asking the
121
+ // stack from inside Radix's own escape callback.
122
+ document.addEventListener('keydown', onKeyDown)
123
+ }
124
+ entries.push(entry)
125
+ return () => {
126
+ const i = entries.indexOf(entry)
127
+ if (i !== -1) entries.splice(i, 1)
128
+ if (entries.length === 0) {
129
+ document.removeEventListener('keydown', onKeyDown)
130
+ return
131
+ }
132
+ // When a layer closes, the caret it held goes with it and lands on
133
+ // <body>, where the next Tab walks whatever is behind every overlay and
134
+ // Escape still works only because this listener is on the document.
135
+ // Hand it to the layer that is now on top. Only from <body>: if
136
+ // something already restored focus — Radix returns it to the trigger —
137
+ // that choice is more specific than this one and stands.
138
+ queueMicrotask(() => {
139
+ if (document.activeElement && document.activeElement !== document.body)
140
+ return
141
+ topElement()?.focus()
142
+ })
143
+ }
144
+ }
145
+
146
+ return { register, dismissTop, topElement }
147
+ }
148
+
149
+ /**
150
+ * The app-wide stack, used whenever no provider scopes one.
151
+ *
152
+ * Module-level so two sibling overlays coordinate even if neither sits under a
153
+ * provider — which is the whole point. Created lazily so importing this module
154
+ * never touches `document`, keeping it safe in a non-DOM environment.
155
+ */
156
+ let appStack: DismissStackApi | null = null
157
+ function getAppStack(): DismissStackApi {
158
+ appStack ??= createStack()
159
+ return appStack
160
+ }
161
+
162
+ const DismissStackContext = createContext<DismissStackApi | null>(null)
163
+
164
+ /**
165
+ * Scopes a stack to a subtree.
166
+ *
167
+ * Only needed when overlays inside must not compete with overlays outside —
168
+ * two independently mounted apps on one page, for instance. The app does not
169
+ * need it for ordinary nesting; the default stack already handles that.
170
+ */
171
+ export function DismissStackProvider({
172
+ children,
173
+ }: {
174
+ children: React.ReactNode
175
+ }) {
176
+ const api = useMemo(() => createStack(), [])
177
+ return <DismissStackContext value={api}>{children}</DismissStackContext>
178
+ }
179
+
180
+ /**
181
+ * Declare this layer dismissable by Escape.
182
+ *
183
+ * ```tsx
184
+ * const ref = useRef<HTMLDivElement>(null)
185
+ * useDismissable(ref, onClose) // always dismissable
186
+ * useDismissable(ref, close, { enabled: isOpen }) // only while open
187
+ * return <div ref={ref} role="dialog">…</div>
188
+ * ```
189
+ *
190
+ * `ref` is the layer's root element, and it is required rather than optional
191
+ * because it IS the ordering: the last enabled layer in the document owns
192
+ * Escape. Call the hook once per layer, unconditionally; nothing else
193
+ * coordinates with any other layer, and adding one above an existing layer
194
+ * needs no change to it.
195
+ */
196
+ export function useDismissable(
197
+ layer: { readonly current: HTMLElement | null },
198
+ onDismiss: () => void,
199
+ { enabled = true }: { enabled?: boolean } = {},
200
+ ): void {
201
+ const scoped = use(DismissStackContext)
202
+ const stack = scoped ?? getAppStack()
203
+
204
+ // A stable entry whose fields are refreshed in place. Handing `register` a
205
+ // new object whenever the callback identity changed would churn the stack on
206
+ // every render for no benefit.
207
+ const entryRef = useRef<DismissEntry>({ layer, onDismiss, enabled })
208
+ useEffect(() => {
209
+ entryRef.current.layer = layer
210
+ entryRef.current.onDismiss = onDismiss
211
+ entryRef.current.enabled = enabled
212
+ }, [layer, onDismiss, enabled])
213
+
214
+ useEffect(() => stack.register(entryRef.current), [stack])
215
+ }
216
+
217
+ /**
218
+ * The stack itself, for a component that owns its own key handling or focus
219
+ * and wants stack ordering instead of inventing its own.
220
+ */
221
+ export function useDismissStack(): DismissStackApi {
222
+ const scoped = use(DismissStackContext)
223
+ return scoped ?? getAppStack()
224
+ }
@@ -0,0 +1,36 @@
1
+ import { createContext, use } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import type { PluginRegistration } from './types'
4
+
5
+ const ExtensionsInternalContext = createContext<
6
+ readonly PluginRegistration[] | undefined
7
+ >(undefined)
8
+
9
+ interface ExtensionsContextProps {
10
+ children: ReactNode
11
+ value?: readonly PluginRegistration[]
12
+ }
13
+
14
+ export function ExtensionsContext({ children, value }: ExtensionsContextProps) {
15
+ return (
16
+ <ExtensionsInternalContext value={value}>
17
+ {children}
18
+ </ExtensionsInternalContext>
19
+ )
20
+ }
21
+
22
+ /**
23
+ * Registered plugins, or an empty list.
24
+ *
25
+ * Empty is the open-source default and has to stay a first-class state: every
26
+ * slot renders nothing and every wrapper passes its children through, so the UI
27
+ * is identical to one with no slots at all. Slots are additive — a slot must
28
+ * never be the only route to something the core itself needs.
29
+ */
30
+ export function useExtensions(): readonly PluginRegistration[] {
31
+ return use(ExtensionsInternalContext) ?? EMPTY
32
+ }
33
+
34
+ // Module-level constant: a fresh `[]` per call would be a new identity every
35
+ // render and defeat memoisation in anything downstream of it.
36
+ const EMPTY: readonly PluginRegistration[] = []
@@ -0,0 +1,19 @@
1
+ import { makeSlot, makeWrapper } from './makeSlot'
2
+
3
+ /**
4
+ * Binds one feature's slot spec, so each slot names itself exactly once and the
5
+ * name is checked against that feature's own spec.
6
+ *
7
+ * const mkSlot = slotFactory<AppCatalogSlots>()
8
+ * export const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')
9
+ * // ^ typo here is a compile error
10
+ */
11
+ export function slotFactory<TSpec>() {
12
+ return <TName extends keyof TSpec & string>(name: TName) =>
13
+ makeSlot<TSpec[TName]>(name)
14
+ }
15
+
16
+ export function wrapperFactory<TSpec>() {
17
+ return <TName extends keyof TSpec & string>(name: TName) =>
18
+ makeWrapper<TSpec[TName]>(name)
19
+ }
@@ -0,0 +1,74 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { slotFactory, wrapperFactory } from '../factories'
3
+ import type { PluginUser } from '../types'
4
+
5
+ /**
6
+ * Leaf slots on the resource detail card.
7
+ *
8
+ * Payloads are additive-only. Adding a field is invisible to existing plugins;
9
+ * renaming or removing one breaks them at compile time on their next core bump,
10
+ * which is the intended moment for that to surface.
11
+ */
12
+ export interface ResourceDetailSlots {
13
+ /**
14
+ * Actions on the detail card's header band.
15
+ *
16
+ * The band and not a tab panel: tab panels mount only while active and
17
+ * Overview is first, so a slot inside a tab is invisible until a second click.
18
+ */
19
+ resourceDetailAccessActions: {
20
+ /** The resource on screen — a top-level app OR one of its sub-resources. */
21
+ resource: Resource
22
+ /** Present only on a sub-resource view. Its absence is how a plugin tells the two apart. */
23
+ parent?: Resource
24
+ /** Children of `parent ?? resource`: the family's sub-resources. */
25
+ subResources: Resource[]
26
+ user: PluginUser | null
27
+ }
28
+
29
+ /**
30
+ * One cell per row of the sub-resources table, in a column of its own.
31
+ *
32
+ * Called once per rendered row, which is the opposite shape to
33
+ * `resourceDetailAccessActions` — that one is called once and handed the whole
34
+ * list. Use this when the action belongs to a single child.
35
+ *
36
+ * Two things to know before filling it. The table renders EVERY row with no
37
+ * virtualisation, so a contribution here is mounted as many times as the
38
+ * family has children (hundreds, in a large catalog) — keep the cell cheap and
39
+ * defer anything expensive until it is interacted with. And the column only
40
+ * appears when a plugin actually fills it, so a build with no plugin renders
41
+ * the table exactly as before.
42
+ */
43
+ resourceSubResourceRowActions: {
44
+ /** The child this row is showing. */
45
+ resource: Resource
46
+ /** The entry the row hangs off. Always present here, unlike the header slot. */
47
+ parent: Resource
48
+ user: PluginUser | null
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Wrappers on the resource detail card.
54
+ *
55
+ * `resourceDetailProvider` is mounted inside the panel's `key={shownSlug}`
56
+ * subtree, so anything a plugin provides here is scoped to the resource on
57
+ * screen and is discarded when the card navigates. That is what lets two slots
58
+ * share per-resource state without the plugin writing any cleanup.
59
+ */
60
+ export interface ResourceDetailWrappers {
61
+ resourceDetailProvider: {
62
+ resource: Resource
63
+ parent?: Resource
64
+ }
65
+ }
66
+
67
+ const mkSlot = slotFactory<ResourceDetailSlots>()
68
+ const mkWrapper = wrapperFactory<ResourceDetailWrappers>()
69
+
70
+ export const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')
71
+ export const ResourceSubResourceRowActions = mkSlot(
72
+ 'resourceSubResourceRowActions',
73
+ )
74
+ export const ResourceDetailProvider = mkWrapper('resourceDetailProvider')
@@ -0,0 +1,121 @@
1
+ import { useMemo } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ // Relative, not `~`: this file is on the published entry's graph and therefore
4
+ // in every consumer's program, and `~` is the CORE's alias — a consumer only
5
+ // resolves it if it has mapped it, which the example app has not.
6
+ import { useOptionalUser } from '../auth/AuthContext'
7
+ import type {
8
+ ResourceDetailSlots,
9
+ ResourceDetailWrappers,
10
+ } from './features/resourceDetail'
11
+ import type { AssertDisjoint, PluginUser } from './types'
12
+
13
+ import { useExtensions } from './ExtensionsContext'
14
+
15
+ export { ExtensionsContext, useExtensions } from './ExtensionsContext'
16
+ export { slotFactory, wrapperFactory } from './factories'
17
+ export { makeSlot, makeWrapper } from './makeSlot'
18
+ export type { PluginUser, PluginRegistration } from './types'
19
+ export type { AssertDisjoint } from './types'
20
+ export {
21
+ ResourceDetailAccessActions,
22
+ ResourceSubResourceRowActions,
23
+ ResourceDetailProvider,
24
+ } from './features/resourceDetail'
25
+ export type {
26
+ ResourceDetailSlots,
27
+ ResourceDetailWrappers,
28
+ } from './features/resourceDetail'
29
+
30
+ /**
31
+ * Every leaf slot in the core, composed from the per-feature specs.
32
+ *
33
+ * Adding a feature means a new `features/<name>.ts` plus one member here and one
34
+ * line in the guard below. No existing feature file is touched.
35
+ */
36
+ export type SlotSpec = ResourceDetailSlots
37
+
38
+ /** Every wrapper slot in the core. */
39
+ export type WrapperSpec = ResourceDetailWrappers
40
+
41
+ // Guard: with a second feature this becomes
42
+ // `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the
43
+ // duplicated id spelled out in the error. With one feature it is trivially true,
44
+ // and is kept so the next feature inherits the check rather than inventing it.
45
+ const _slotIdsAreDisjoint: AssertDisjoint<SlotSpec, Record<never, never>> = true
46
+ void _slotIdsAreDisjoint
47
+
48
+ /**
49
+ * A plugin's leaf-slot handlers. Each receives exactly its slot's payload.
50
+ *
51
+ * Registering an unknown slot, or reading a field the slot does not pass, is a
52
+ * compile error — which is the whole point of composing `SlotSpec` rather than
53
+ * typing handlers as `(props: unknown)`.
54
+ */
55
+ export type PluginSlots = {
56
+ [N in keyof SlotSpec]?: (props: SlotSpec[N]) => ReactNode
57
+ }
58
+
59
+ /** A plugin's wrapper handlers. `children` is injected by the core. */
60
+ export type PluginWrappers = {
61
+ [N in keyof WrapperSpec]?: (
62
+ props: WrapperSpec[N] & { children: ReactNode },
63
+ ) => ReactNode
64
+ }
65
+
66
+ /**
67
+ * One deployment-specific feature's contribution to the core's UI.
68
+ *
69
+ * Passed to `<App extensions={[...]} />` as an array, not a keyed map: a map
70
+ * holds one value per key, so a second plugin at the same slot would silently
71
+ * replace the first and two wrappers around one subtree would be impossible.
72
+ *
73
+ * The array must be a module-level constant. The core iterates it in render
74
+ * order, so rebuilding it per render would change hook call order inside the
75
+ * plugins and violate the rules of hooks.
76
+ */
77
+ export interface AcPlugin {
78
+ /** Identity, used in error reports and test assertions. */
79
+ name: string
80
+ slots?: PluginSlots
81
+ wrappers?: PluginWrappers
82
+ }
83
+
84
+ /**
85
+ * Whether any registered plugin fills this slot.
86
+ *
87
+ * For the cases where rendering the slot is not enough and the core has to
88
+ * change its own layout around it — a table column, say, which would otherwise
89
+ * be an empty column with a header and no cells in every build that registers
90
+ * nothing. Rendering an empty slot costs nothing; reserving space for one is
91
+ * what this is for.
92
+ *
93
+ * Use it ONLY for layout. A slot must never be the only route to something the
94
+ * core itself needs, so branching behaviour on a plugin's presence is a design
95
+ * smell even where branching layout is not.
96
+ */
97
+ export function useSlotFilled(slot: keyof SlotSpec): boolean {
98
+ return useExtensions().some((plugin) => Boolean(plugin.slots?.[slot]))
99
+ }
100
+
101
+ /**
102
+ * The signed-in user, reduced to what slots are given.
103
+ *
104
+ * One place, so two slots cannot disagree about the shape — and deliberately a
105
+ * projection rather than the real user object: the core's `User` is free to grow
106
+ * fields, and handing a plugin the whole thing would make every one of them part
107
+ * of the published slot contract.
108
+ *
109
+ * Returns `null` with no `AuthProvider` above it. The detail card renders inside
110
+ * the catalog panel, which some suites mount outside the provider, and throwing
111
+ * there would make a plugin's presence break unrelated tests.
112
+ */
113
+ export function usePluginUser(): PluginUser | null {
114
+ const user = useOptionalUser()
115
+ return useMemo(
116
+ // `?? false`, because admin is a capability: absent must read as "not an
117
+ // admin", never as undefined leaking into a plugin's own check.
118
+ () => (user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null),
119
+ [user],
120
+ )
121
+ }