@igstack/app-catalog-frontend-core 0.14.0 → 0.14.1

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.
@@ -29,6 +29,7 @@ function ScreenshotGallery({
29
29
  className: "h-[85vh] w-full max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-3rem)] md:max-w-[calc(100vw-4rem)] p-0 overflow-hidden",
30
30
  showCloseButton: true,
31
31
  onEscapeKeyDown: (e) => {
32
+ e.stopPropagation();
32
33
  if (isFullscreenRef.current) {
33
34
  e.preventDefault();
34
35
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ScreenshotGallery.js","sources":["../../../../../../src/modules/appCatalog/ui/components/ScreenshotGallery.tsx"],"sourcesContent":["import { useMemo, useRef } from 'react'\n\nimport type { Resource } from '@igstack/app-catalog-backend-core'\n\nimport { Gallery } from '~/modules/gallery/Gallery'\nimport type { GalleryImage } from '~/modules/gallery/Gallery'\nimport { Dialog, DialogContent, DialogTitle } from '~/ui/dialog'\nimport { VisuallyHidden } from '~/ui/visually-hidden'\n\nexport interface ScreenshotGalleryProps {\n app: Resource\n screenshotIds: string[]\n initialIndex?: number\n open: boolean\n onOpenChange: (open: boolean) => void\n title?: string\n}\n\nexport function ScreenshotGallery({\n app,\n screenshotIds,\n initialIndex = 0,\n open,\n onOpenChange,\n title,\n}: ScreenshotGalleryProps) {\n // Track whether Gallery is in fullscreen — if so, block Radix from closing on Escape\n const isFullscreenRef = useRef(false)\n\n // Transform screenshot IDs to full URLs\n const images: GalleryImage[] = useMemo(\n () =>\n screenshotIds.map((id) => ({\n url: `/api/screenshots/${id}`,\n alt: `${app.abbreviation || app.displayName} screenshot`,\n })),\n [screenshotIds, app.abbreviation, app.displayName],\n )\n\n // Don't render if no screenshots\n if (screenshotIds.length === 0) {\n return null\n }\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent\n aria-describedby={undefined}\n className=\"h-[85vh] w-full max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-3rem)] md:max-w-[calc(100vw-4rem)] p-0 overflow-hidden\"\n showCloseButton={true}\n onEscapeKeyDown={(e) => {\n // If Gallery is in fullscreen, its capture listener already handled Escape.\n // Prevent Radix from also closing the dialog on the same event.\n if (isFullscreenRef.current) {\n e.preventDefault()\n }\n }}\n >\n <VisuallyHidden>\n <DialogTitle>\n {title || `${app.abbreviation || app.displayName} screenshots`}\n </DialogTitle>\n </VisuallyHidden>\n <Gallery\n images={images}\n initialIndex={initialIndex}\n title={title}\n onFullscreenChange={(fs) => {\n isFullscreenRef.current = fs\n }}\n />\n </DialogContent>\n </Dialog>\n )\n}\n"],"names":[],"mappings":";;;;;AAkBO,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA;AACF,GAA2B;AAEzB,QAAM,kBAAkB,OAAO,KAAK;AAGpC,QAAM,SAAyB;AAAA,IAC7B,MACE,cAAc,IAAI,CAAC,QAAQ;AAAA,MACzB,KAAK,oBAAoB,EAAE;AAAA,MAC3B,KAAK,GAAG,IAAI,gBAAgB,IAAI,WAAW;AAAA,IAAA,EAC3C;AAAA,IACJ,CAAC,eAAe,IAAI,cAAc,IAAI,WAAW;AAAA,EAAA;AAInD,MAAI,cAAc,WAAW,GAAG;AAC9B,WAAO;AAAA,EACT;AAEA,SACE,oBAAC,QAAA,EAAO,MAAY,cAClB,UAAA;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,oBAAkB;AAAA,MAClB,WAAU;AAAA,MACV,iBAAiB;AAAA,MACjB,iBAAiB,CAAC,MAAM;AAGtB,YAAI,gBAAgB,SAAS;AAC3B,YAAE,eAAA;AAAA,QACJ;AAAA,MACF;AAAA,MAEA,UAAA;AAAA,QAAA,oBAAC,gBAAA,EACC,UAAA,oBAAC,aAAA,EACE,UAAA,SAAS,GAAG,IAAI,gBAAgB,IAAI,WAAW,eAAA,CAClD,GACF;AAAA,QACA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC;AAAA,YACA;AAAA,YACA;AAAA,YACA,oBAAoB,CAAC,OAAO;AAC1B,8BAAgB,UAAU;AAAA,YAC5B;AAAA,UAAA;AAAA,QAAA;AAAA,MACF;AAAA,IAAA;AAAA,EAAA,GAEJ;AAEJ;"}
1
+ {"version":3,"file":"ScreenshotGallery.js","sources":["../../../../../../src/modules/appCatalog/ui/components/ScreenshotGallery.tsx"],"sourcesContent":["import { useMemo, useRef } from 'react'\n\nimport type { Resource } from '@igstack/app-catalog-backend-core'\n\nimport { Gallery } from '~/modules/gallery/Gallery'\nimport type { GalleryImage } from '~/modules/gallery/Gallery'\nimport { Dialog, DialogContent, DialogTitle } from '~/ui/dialog'\nimport { VisuallyHidden } from '~/ui/visually-hidden'\n\nexport interface ScreenshotGalleryProps {\n app: Resource\n screenshotIds: string[]\n initialIndex?: number\n open: boolean\n onOpenChange: (open: boolean) => void\n title?: string\n}\n\nexport function ScreenshotGallery({\n app,\n screenshotIds,\n initialIndex = 0,\n open,\n onOpenChange,\n title,\n}: ScreenshotGalleryProps) {\n // Track whether Gallery is in fullscreen — if so, block Radix from closing on Escape\n const isFullscreenRef = useRef(false)\n\n // Transform screenshot IDs to full URLs\n const images: GalleryImage[] = useMemo(\n () =>\n screenshotIds.map((id) => ({\n url: `/api/screenshots/${id}`,\n alt: `${app.abbreviation || app.displayName} screenshot`,\n })),\n [screenshotIds, app.abbreviation, app.displayName],\n )\n\n // Don't render if no screenshots\n if (screenshotIds.length === 0) {\n return null\n }\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent\n aria-describedby={undefined}\n className=\"h-[85vh] w-full max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-3rem)] md:max-w-[calc(100vw-4rem)] p-0 overflow-hidden\"\n showCloseButton={true}\n onEscapeKeyDown={(e) => {\n // Stop propagation so the Esc event doesn't reach outer document listeners\n // (e.g. SearchResultsList's raw keydown handler, which would clear search).\n e.stopPropagation()\n // If Gallery is in fullscreen, its capture listener already handled Escape.\n // Prevent Radix from also closing the dialog on the same event.\n if (isFullscreenRef.current) {\n e.preventDefault()\n }\n }}\n >\n <VisuallyHidden>\n <DialogTitle>\n {title || `${app.abbreviation || app.displayName} screenshots`}\n </DialogTitle>\n </VisuallyHidden>\n <Gallery\n images={images}\n initialIndex={initialIndex}\n title={title}\n onFullscreenChange={(fs) => {\n isFullscreenRef.current = fs\n }}\n />\n </DialogContent>\n </Dialog>\n )\n}\n"],"names":[],"mappings":";;;;;AAkBO,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA;AACF,GAA2B;AAEzB,QAAM,kBAAkB,OAAO,KAAK;AAGpC,QAAM,SAAyB;AAAA,IAC7B,MACE,cAAc,IAAI,CAAC,QAAQ;AAAA,MACzB,KAAK,oBAAoB,EAAE;AAAA,MAC3B,KAAK,GAAG,IAAI,gBAAgB,IAAI,WAAW;AAAA,IAAA,EAC3C;AAAA,IACJ,CAAC,eAAe,IAAI,cAAc,IAAI,WAAW;AAAA,EAAA;AAInD,MAAI,cAAc,WAAW,GAAG;AAC9B,WAAO;AAAA,EACT;AAEA,SACE,oBAAC,QAAA,EAAO,MAAY,cAClB,UAAA;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,oBAAkB;AAAA,MAClB,WAAU;AAAA,MACV,iBAAiB;AAAA,MACjB,iBAAiB,CAAC,MAAM;AAGtB,UAAE,gBAAA;AAGF,YAAI,gBAAgB,SAAS;AAC3B,YAAE,eAAA;AAAA,QACJ;AAAA,MACF;AAAA,MAEA,UAAA;AAAA,QAAA,oBAAC,gBAAA,EACC,UAAA,oBAAC,aAAA,EACE,UAAA,SAAS,GAAG,IAAI,gBAAgB,IAAI,WAAW,eAAA,CAClD,GACF;AAAA,QACA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC;AAAA,YACA;AAAA,YACA;AAAA,YACA,oBAAoB,CAAC,OAAO;AAC1B,8BAAgB,UAAU;AAAA,YAC5B;AAAA,UAAA;AAAA,QAAA;AAAA,MACF;AAAA,IAAA;AAAA,EAAA,GAEJ;AAEJ;"}
@@ -1,11 +1,17 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
2
  import { X } from "lucide-react";
3
+ import { useRef, useEffect } from "react";
3
4
  import { AppDetails } from "../grid/AppCatalogGrid.js";
4
5
  function LauncherDetailPanel({
5
6
  app,
6
7
  onClose,
7
8
  onAppClick
8
9
  }) {
10
+ const dialogRef = useRef(null);
11
+ useEffect(() => {
12
+ var _a;
13
+ (_a = dialogRef.current) == null ? void 0 : _a.focus();
14
+ }, []);
9
15
  return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10", children: [
10
16
  /* @__PURE__ */ jsx(
11
17
  "button",
@@ -19,10 +25,12 @@ function LauncherDetailPanel({
19
25
  /* @__PURE__ */ jsxs(
20
26
  "div",
21
27
  {
28
+ ref: dialogRef,
22
29
  role: "dialog",
23
30
  "aria-modal": "true",
24
31
  "aria-label": `${app.displayName} details`,
25
- className: "relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200",
32
+ tabIndex: -1,
33
+ className: "relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none",
26
34
  children: [
27
35
  /* @__PURE__ */ jsx(
28
36
  "button",
@@ -1 +1 @@
1
- {"version":3,"file":"LauncherDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { AppDetails } from '../grid/AppCatalogGrid'\n\n/**\n * Centered detail card for the launcher (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the launcher backdrop, instead of the old grid + resizable split-pane.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function LauncherDetailPanel({\n app,\n onClose,\n onAppClick,\n}: {\n app: Resource\n onClose: () => void\n onAppClick?: (app: Resource) => void\n}) {\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${app.displayName} details`}\n className=\"relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200\"\n >\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={onClose}\n className=\"absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n <div className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\">\n <AppDetails\n app={app}\n onAppClick={onAppClick}\n onClosePanel={onClose}\n />\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;;AAcO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,SACE,qBAAC,OAAA,EAAI,WAAU,yFAEb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,SAAS;AAAA,QACT,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAMZ;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,GAAG,IAAI,WAAW;AAAA,QAC9B,WAAU;AAAA,QAEV,UAAA;AAAA,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,cAAW;AAAA,cACX,SAAS;AAAA,cACT,WAAU;AAAA,cAEV,UAAA,oBAAC,GAAA,EAAE,WAAU,SAAA,CAAS;AAAA,YAAA;AAAA,UAAA;AAAA,UAExB,oBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,cAAc;AAAA,YAAA;AAAA,UAAA,EAChB,CACF;AAAA,QAAA;AAAA,MAAA;AAAA,IAAA;AAAA,EACF,GACF;AAEJ;"}
1
+ {"version":3,"file":"LauncherDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { useEffect, useRef } from 'react'\nimport { AppDetails } from '../grid/AppCatalogGrid'\n\n/**\n * Centered detail card for the launcher (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the launcher backdrop, instead of the old grid + resizable split-pane.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function LauncherDetailPanel({\n app,\n onClose,\n onAppClick,\n}: {\n app: Resource\n onClose: () => void\n onAppClick?: (app: Resource) => void\n}) {\n const dialogRef = useRef<HTMLDivElement>(null)\n\n // Focus the dialog on mount so Esc hotkeys fire immediately, even when\n // the card was opened by a mouse click (which leaves focus on the grid row).\n useEffect(() => {\n dialogRef.current?.focus()\n }, [])\n\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n ref={dialogRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${app.displayName} details`}\n tabIndex={-1}\n className=\"relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none\"\n >\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={onClose}\n className=\"absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n <div className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\">\n <AppDetails\n app={app}\n onAppClick={onAppClick}\n onClosePanel={onClose}\n />\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;;;AAeO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,QAAM,YAAY,OAAuB,IAAI;AAI7C,YAAU,MAAM;;AACd,oBAAU,YAAV,mBAAmB;AAAA,EACrB,GAAG,CAAA,CAAE;AAEL,SACE,qBAAC,OAAA,EAAI,WAAU,yFAEb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,SAAS;AAAA,QACT,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAMZ;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,GAAG,IAAI,WAAW;AAAA,QAC9B,UAAU;AAAA,QACV,WAAU;AAAA,QAEV,UAAA;AAAA,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,cAAW;AAAA,cACX,SAAS;AAAA,cACT,WAAU;AAAA,cAEV,UAAA,oBAAC,GAAA,EAAE,WAAU,SAAA,CAAS;AAAA,YAAA;AAAA,UAAA;AAAA,UAExB,oBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,cAAc;AAAA,YAAA;AAAA,UAAA,EAChB,CACF;AAAA,QAAA;AAAA,MAAA;AAAA,IAAA;AAAA,EACF,GACF;AAEJ;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -134,8 +134,8 @@
134
134
  "vite-plugin-static-copy": "^3.1.4",
135
135
  "vite-plugin-svgr": "^4.2.0",
136
136
  "vitest": "^4.1.2",
137
- "@igstack/app-catalog-shared-core": "0.14.0",
138
- "@igstack/app-catalog-backend-core": "0.14.0"
137
+ "@igstack/app-catalog-shared-core": "0.14.1",
138
+ "@igstack/app-catalog-backend-core": "0.14.1"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "react": "19.1.2",
@@ -144,7 +144,7 @@
144
144
  "vite": "^6.3.5",
145
145
  "vite-plugin-svgr": "^4.2.0"
146
146
  },
147
- "gitHead": "f7e30b3da50d80d10ed19ddd3be273329495bdcb",
147
+ "gitHead": "30c7add877ea524033c3f4e96b4e4c56a1a933e8",
148
148
  "scripts": {
149
149
  "build": "vite build",
150
150
  "build:lenient": "vite build --mode lenient",
@@ -45,6 +45,17 @@ describe('App deep-link routing', () => {
45
45
  expect(router.state.location.pathname).toBe('/')
46
46
  })
47
47
 
48
+ it('Esc closes card opened by mouse click (#53 bug 1)', async () => {
49
+ const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
50
+ // Open via click (mouse, not keyboard nav)
51
+ await ui.catalog.openApp('Jira')
52
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
53
+ // Esc should close — no "dead click" needed
54
+ fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
55
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(false))
56
+ expect(router.state.location.pathname).toBe('/')
57
+ })
58
+
48
59
  it('closes the detail panel when the × close button is clicked (#59)', async () => {
49
60
  const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
50
61
  await ui.catalog.openApp('Jira')
@@ -49,6 +49,9 @@ export function ScreenshotGallery({
49
49
  className="h-[85vh] w-full max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-3rem)] md:max-w-[calc(100vw-4rem)] p-0 overflow-hidden"
50
50
  showCloseButton={true}
51
51
  onEscapeKeyDown={(e) => {
52
+ // Stop propagation so the Esc event doesn't reach outer document listeners
53
+ // (e.g. SearchResultsList's raw keydown handler, which would clear search).
54
+ e.stopPropagation()
52
55
  // If Gallery is in fullscreen, its capture listener already handled Escape.
53
56
  // Prevent Radix from also closing the dialog on the same event.
54
57
  if (isFullscreenRef.current) {
@@ -1,5 +1,6 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import { X } from 'lucide-react'
3
+ import { useEffect, useRef } from 'react'
3
4
  import { AppDetails } from '../grid/AppCatalogGrid'
4
5
 
5
6
  /**
@@ -21,6 +22,14 @@ export function LauncherDetailPanel({
21
22
  onClose: () => void
22
23
  onAppClick?: (app: Resource) => void
23
24
  }) {
25
+ const dialogRef = useRef<HTMLDivElement>(null)
26
+
27
+ // Focus the dialog on mount so Esc hotkeys fire immediately, even when
28
+ // the card was opened by a mouse click (which leaves focus on the grid row).
29
+ useEffect(() => {
30
+ dialogRef.current?.focus()
31
+ }, [])
32
+
24
33
  return (
25
34
  <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
26
35
  {/* scrim */}
@@ -35,10 +44,12 @@ export function LauncherDetailPanel({
35
44
  prose inside AppDetails is width-capped separately so it stays
36
45
  readable. Caps at 94vw so it never touches the edges. */}
37
46
  <div
47
+ ref={dialogRef}
38
48
  role="dialog"
39
49
  aria-modal="true"
40
50
  aria-label={`${app.displayName} details`}
41
- className="relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200"
51
+ tabIndex={-1}
52
+ className="relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none"
42
53
  >
43
54
  <button
44
55
  type="button"