diff --git a/assets/ts/dialog.ts b/assets/ts/dialog.ts index aa12a5d..57f1ddb 100644 --- a/assets/ts/dialog.ts +++ b/assets/ts/dialog.ts @@ -12,9 +12,27 @@ export const ShadixDialog = { const el = this.el; let restoreTo: HTMLElement | null = null; + const lockScroll = () => { + // Lock body scroll. Compensate for the scrollbar width so removing the + // scrollbar doesn't cause a horizontal layout shift — without this, the + // page content (and any fixed/centered dialog) jumps by the scrollbar + // width when overflow becomes hidden. Mirrors Radix/shadcn dialogs. + const scrollbarWidth = + window.innerWidth - document.documentElement.clientWidth; + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + document.body.style.overflow = "hidden"; + }; + + const unlockScroll = () => { + document.body.style.overflow = ""; + document.body.style.paddingRight = ""; + }; + const open = () => { restoreTo = document.activeElement as HTMLElement | null; - document.body.style.overflow = "hidden"; + lockScroll(); window.requestAnimationFrame(() => { const content = el.querySelector('[role="dialog"]'); const first = content?.querySelector(FOCUSABLE); @@ -23,7 +41,7 @@ export const ShadixDialog = { }; const close = () => { - document.body.style.overflow = ""; + unlockScroll(); restoreTo?.focus(); restoreTo = null; }; @@ -34,5 +52,6 @@ export const ShadixDialog = { destroyed(this: DialogHook) { document.body.style.overflow = ""; + document.body.style.paddingRight = ""; }, }; diff --git a/priv/registry/dialog.json b/priv/registry/dialog.json index d567488..82a3e2c 100644 --- a/priv/registry/dialog.json +++ b/priv/registry/dialog.json @@ -8,7 +8,7 @@ ], "hooks": [ { - "content": "interface DialogHook {\n el: HTMLElement;\n mounted(): void;\n destroyed(): void;\n}\n\nconst FOCUSABLE =\n 'a[href],area[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nexport const ShadixDialog = {\n mounted(this: DialogHook) {\n const el = this.el;\n let restoreTo: HTMLElement | null = null;\n\n const open = () => {\n restoreTo = document.activeElement as HTMLElement | null;\n document.body.style.overflow = \"hidden\";\n window.requestAnimationFrame(() => {\n const content = el.querySelector('[role=\"dialog\"]');\n const first = content?.querySelector(FOCUSABLE);\n (first ?? content)?.focus();\n });\n };\n\n const close = () => {\n document.body.style.overflow = \"\";\n restoreTo?.focus();\n restoreTo = null;\n };\n\n el.addEventListener(\"shadix:dialog-open\", open);\n el.addEventListener(\"shadix:dialog-close\", close);\n },\n\n destroyed(this: DialogHook) {\n document.body.style.overflow = \"\";\n },\n};\n", + "content": "interface DialogHook {\n el: HTMLElement;\n mounted(): void;\n destroyed(): void;\n}\n\nconst FOCUSABLE =\n 'a[href],area[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nexport const ShadixDialog = {\n mounted(this: DialogHook) {\n const el = this.el;\n let restoreTo: HTMLElement | null = null;\n\n const lockScroll = () => {\n // Lock body scroll. Compensate for the scrollbar width so removing the\n // scrollbar doesn't cause a horizontal layout shift — without this, the\n // page content (and any fixed/centered dialog) jumps by the scrollbar\n // width when overflow becomes hidden. Mirrors Radix/shadcn dialogs.\n const scrollbarWidth =\n window.innerWidth - document.documentElement.clientWidth;\n if (scrollbarWidth > 0) {\n document.body.style.paddingRight = `${scrollbarWidth}px`;\n }\n document.body.style.overflow = \"hidden\";\n };\n\n const unlockScroll = () => {\n document.body.style.overflow = \"\";\n document.body.style.paddingRight = \"\";\n };\n\n const open = () => {\n restoreTo = document.activeElement as HTMLElement | null;\n lockScroll();\n window.requestAnimationFrame(() => {\n const content = el.querySelector('[role=\"dialog\"]');\n const first = content?.querySelector(FOCUSABLE);\n (first ?? content)?.focus();\n });\n };\n\n const close = () => {\n unlockScroll();\n restoreTo?.focus();\n restoreTo = null;\n };\n\n el.addEventListener(\"shadix:dialog-open\", open);\n el.addEventListener(\"shadix:dialog-close\", close);\n },\n\n destroyed(this: DialogHook) {\n document.body.style.overflow = \"\";\n document.body.style.paddingRight = \"\";\n },\n};\n", "name": "ShadixDialog", "path": "dialog.ts" }