diff --git a/resources/js/packages/ui/src/CommandPalette/CommandPalette.vue b/resources/js/packages/ui/src/CommandPalette/CommandPalette.vue
index 57317c40..55e99017 100644
--- a/resources/js/packages/ui/src/CommandPalette/CommandPalette.vue
+++ b/resources/js/packages/ui/src/CommandPalette/CommandPalette.vue
@@ -11,6 +11,7 @@ import {
CommandShortcut,
} from '../command';
import { cn } from '../utils/cn';
+import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import type {
CommandPaletteCommand,
CommandPaletteGroup,
@@ -36,6 +37,8 @@ const emit = defineEmits<{
select: [command: CommandPaletteCommand | EntitySearchResult];
}>();
+const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
+
// Non-empty groups for rendering
const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));
@@ -71,7 +74,9 @@ watch(open, (isOpen) => {
)
">
+ class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 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"
+ @open-auto-focus="onOpenAutoFocus"
+ @close-auto-focus="onCloseAutoFocus">
diff --git a/resources/js/packages/ui/src/dialog/DialogContent.vue b/resources/js/packages/ui/src/dialog/DialogContent.vue
index 0ae3b68d..a63b8e79 100644
--- a/resources/js/packages/ui/src/dialog/DialogContent.vue
+++ b/resources/js/packages/ui/src/dialog/DialogContent.vue
@@ -1,5 +1,6 @@
@@ -36,7 +41,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none 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',
props.class
)
- ">
+ "
+ @open-auto-focus="onOpenAutoFocus"
+ @close-auto-focus="onCloseAutoFocus">
diff --git a/resources/js/packages/ui/src/dialog/DialogScrollContent.vue b/resources/js/packages/ui/src/dialog/DialogScrollContent.vue
index f2c0d3e4..87a5671a 100644
--- a/resources/js/packages/ui/src/dialog/DialogScrollContent.vue
+++ b/resources/js/packages/ui/src/dialog/DialogScrollContent.vue
@@ -1,5 +1,6 @@
@@ -36,6 +41,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
)
"
v-bind="forwarded"
+ @open-auto-focus="onOpenAutoFocus"
+ @close-auto-focus="onCloseAutoFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
diff --git a/resources/js/packages/ui/src/utils/useDialogFocusRestore.test.ts b/resources/js/packages/ui/src/utils/useDialogFocusRestore.test.ts
new file mode 100644
index 00000000..13b13351
--- /dev/null
+++ b/resources/js/packages/ui/src/utils/useDialogFocusRestore.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it, vi } from 'vitest';
+import { useDialogFocusRestore } from './useDialogFocusRestore';
+
+function closeEvent() {
+ return new CustomEvent('focusScope.autoFocusOnUnmount', { cancelable: true });
+}
+
+describe('useDialogFocusRestore', () => {
+ it('restores focus to the element focused when the dialog opened', () => {
+ vi.useFakeTimers();
+ const button = document.createElement('button');
+ document.body.appendChild(button);
+ button.focus();
+
+ const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
+ onOpenAutoFocus();
+ button.blur();
+
+ const event = closeEvent();
+ onCloseAutoFocus(event);
+ expect(event.defaultPrevented).toBe(true);
+ vi.runAllTimers();
+ expect(document.activeElement).toBe(button);
+
+ button.remove();
+ vi.useRealTimers();
+ });
+
+ it('focuses nothing when the dialog was opened with nothing focused', () => {
+ vi.useFakeTimers();
+ const stale = document.createElement('button');
+ document.body.appendChild(stale);
+ const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
+
+ // First open from the button, then close
+ stale.focus();
+ onOpenAutoFocus();
+ onCloseAutoFocus(closeEvent());
+ vi.runAllTimers();
+ stale.blur();
+
+ // Second open from the body must not refocus the stale button
+ onOpenAutoFocus();
+ const event = closeEvent();
+ onCloseAutoFocus(event);
+ expect(event.defaultPrevented).toBe(true);
+ vi.runAllTimers();
+ expect(document.activeElement).toBe(document.body);
+
+ stale.remove();
+ vi.useRealTimers();
+ });
+
+ it('does not restore focus to an element that was removed', () => {
+ vi.useFakeTimers();
+ const button = document.createElement('button');
+ document.body.appendChild(button);
+ button.focus();
+ const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
+ onOpenAutoFocus();
+ button.remove();
+
+ onCloseAutoFocus(closeEvent());
+ vi.runAllTimers();
+ expect(document.activeElement).toBe(document.body);
+ vi.useRealTimers();
+ });
+
+ it('leaves control to a consumer that already prevented the event', () => {
+ vi.useFakeTimers();
+ const button = document.createElement('button');
+ document.body.appendChild(button);
+ button.focus();
+ const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
+ onOpenAutoFocus();
+ button.blur();
+
+ const event = closeEvent();
+ event.preventDefault();
+ onCloseAutoFocus(event);
+ vi.runAllTimers();
+ expect(document.activeElement).toBe(document.body);
+
+ button.remove();
+ vi.useRealTimers();
+ });
+});
diff --git a/resources/js/packages/ui/src/utils/useDialogFocusRestore.ts b/resources/js/packages/ui/src/utils/useDialogFocusRestore.ts
new file mode 100644
index 00000000..807e7868
--- /dev/null
+++ b/resources/js/packages/ui/src/utils/useDialogFocusRestore.ts
@@ -0,0 +1,41 @@
+/**
+ * Restores focus to the element that was focused when a dialog opened.
+ *
+ * reka-ui remembers the active element at content mount as the dialog's
+ * "trigger" (only when it is not the body) and refocuses it on every close,
+ * but it never clears that value. A dialog opened while nothing is focused
+ * (e.g. the command palette via Cmd+K from the body) therefore refocuses
+ * whatever triggered a *previous* open. Bind these handlers to
+ * `DialogContent`'s `open-auto-focus` / `close-auto-focus` events to restore
+ * exactly the previously focused element, or nothing.
+ *
+ * A consumer handler that already called `preventDefault()` on
+ * `close-auto-focus` keeps control; this composable then does nothing.
+ */
+export function useDialogFocusRestore() {
+ let previouslyFocused: HTMLElement | null = null;
+
+ function onOpenAutoFocus() {
+ const active = document.activeElement;
+ previouslyFocused =
+ active instanceof HTMLElement && active !== document.body ? active : null;
+ }
+
+ function onCloseAutoFocus(event: Event) {
+ const target = previouslyFocused;
+ previouslyFocused = null;
+ if (event.defaultPrevented) {
+ return;
+ }
+ // Prevents both FocusScope's default restore and reka-ui's trigger refocus
+ event.preventDefault();
+ // Same tick reka-ui uses, so the dialog content is fully gone first
+ setTimeout(() => {
+ if (target?.isConnected) {
+ target.focus({ preventScroll: true });
+ }
+ }, 0);
+ }
+
+ return { onOpenAutoFocus, onCloseAutoFocus };
+}