diff --git a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts index 5c108ba3..b63fd1b0 100644 --- a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts @@ -1,4 +1,5 @@ import type { HTMLAttributes } from "svelte/elements"; +import { FOCUSABLE_ATTRIBUTE } from "../internal/get-floating-focus-element.js"; const ACTIVE_KEY = "active"; const SELECTED_KEY = "selected"; @@ -44,7 +45,10 @@ function mergeProps( } return { - ...(elementKey === "floating" && { tabindex: -1 }), + ...(elementKey === "floating" && { + tabindex: -1, + [FOCUSABLE_ATTRIBUTE]: "", + }), ...domUserProps, ...propsList .map((value) => { diff --git a/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts index 21442e62..9ebfbfa1 100644 --- a/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts +++ b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts @@ -8,8 +8,10 @@ export function getFloatingFocusElement( // This indicates the floating element is acting as a positioning wrapper, and // so focus should be managed on the child element with the event handlers and // aria props. - return floatingElement.hasAttribute(FOCUSABLE_ATTRIBUTE) + const res = floatingElement.hasAttribute(FOCUSABLE_ATTRIBUTE) ? floatingElement : floatingElement.querySelector(`[${FOCUSABLE_ATTRIBUTE}]`) || - floatingElement; + floatingElement; + console.log(res); + return res as HTMLElement; } diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected-drawer.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected-drawer.svelte new file mode 100644 index 00000000..ca0e8696 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected-drawer.svelte @@ -0,0 +1,26 @@ + + + +
+ +
+
diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected.svelte new file mode 100644 index 00000000..4323aa47 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected.svelte @@ -0,0 +1,45 @@ + + + +{#if open} + +
+ Parent Floating + +
+
+{/if} +{#if isDrawerOpen} + (isDrawerOpen = v)} /> +{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte new file mode 100644 index 00000000..e4c9a518 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte @@ -0,0 +1,35 @@ + + + + +{#if open} + +
+
+
+
+{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/restore-focus.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/restore-focus.svelte new file mode 100644 index 00000000..277b59f9 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/restore-focus.svelte @@ -0,0 +1,51 @@ + + + +{#if open} + +
+ {#if removedIndex < 3} + + {/if} + {#if removedIndex < 1} + + {/if} + {#if removedIndex < 2} + + {/if} +
+
+{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/trapped-combobox.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/trapped-combobox.svelte new file mode 100644 index 00000000..7b9b69fc --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/trapped-combobox.svelte @@ -0,0 +1,45 @@ + + +
+ + + {#if open} + +
+ + +
+
+ {/if} +
diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/untrapped-combobox.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/untrapped-combobox.svelte new file mode 100644 index 00000000..b8fc064b --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/untrapped-combobox.svelte @@ -0,0 +1,52 @@ + + +
+ + + {#if open} + + +
+ + +
+
+
+ {/if} + +
diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index 18e67885..2b9fa927 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -15,6 +15,11 @@ import KeepMounted from "./components/keep-mounted.svelte"; import NonModalFloatingPortal from "./components/non-modal-floating-portal.svelte"; import Navigation from "../navigation/main.svelte"; import Drawer from "../drawer/main.svelte"; +import RestoreFocus from "./components/restore-focus.svelte"; +import TrappedCombobox from "./components/trapped-combobox.svelte"; +import UntrappedCombobox from "./components/untrapped-combobox.svelte"; +import Connected from "./components/connected.svelte"; +import FloatingWrapper from "./components/floating-wrapper.svelte"; describe("initialFocus", () => { it("handles numbers", async () => { @@ -653,3 +658,98 @@ describe("Drawer", () => { await waitFor(() => expect(screen.getByText("My button")).toHaveFocus()); }); }); + +describe("restoreFocus", () => { + it("true: restores focus to nearest tabbable element if currently focused element is removed", async () => { + render(RestoreFocus); + + await userEvent.click(screen.getByTestId("reference")); + + const one = screen.getByRole("button", { name: "one" }); + const two = screen.getByRole("button", { name: "two" }); + const three = screen.getByRole("button", { name: "three" }); + const floating = screen.getByTestId("floating"); + + await waitFor(() => expect(one).toHaveFocus()); + await fireEvent.click(one); + await fireEvent.focusOut(floating); + + expect(two).toHaveFocus(); + await fireEvent.click(two); + await fireEvent.focusOut(floating); + + expect(three).toHaveFocus(); + await fireEvent.click(three); + await fireEvent.focusOut(floating); + + expect(floating).toHaveFocus(); + }); + + it("false: does not restore focus to nearest tabbable element if currently focused element is removed", async () => { + render(RestoreFocus, { restoreFocus: false }); + + await userEvent.click(screen.getByTestId("reference")); + + const one = screen.getByRole("button", { name: "one" }); + const floating = screen.getByTestId("floating"); + + await waitFor(() => expect(one).toHaveFocus()); + await fireEvent.click(one); + await fireEvent.focusOut(floating); + + expect(document.body).toHaveFocus(); + }); +}); + +it("trapped combobox prevents focus moving outside floating element", async () => { + render(TrappedCombobox); + await userEvent.click(screen.getByTestId("input")); + await waitFor(() => expect(screen.getByTestId("input")).not.toHaveFocus()); + await waitFor(() => + expect(screen.getByRole("button", { name: "one" })).toHaveFocus(), + ); + await userEvent.tab(); + await waitFor(() => + expect(screen.getByRole("button", { name: "two" })).toHaveFocus(), + ); + await userEvent.tab(); + await waitFor(() => + expect(screen.getByRole("button", { name: "one" })).toHaveFocus(), + ); +}); + +it("untrapped combobox creates non-modal focus management", async () => { + render(UntrappedCombobox); + await userEvent.click(screen.getByTestId("input")); + await waitFor(() => expect(screen.getByTestId("input")).toHaveFocus()); + await userEvent.tab(); + await waitFor(() => + expect(screen.getByRole("button", { name: "one" })).toHaveFocus(), + ); + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("input")).toHaveFocus()); +}); + +it("returns focus to the last connected element", async () => { + render(Connected); + await userEvent.click(screen.getByTestId("parent-reference")); + await waitFor(() => + expect(screen.getByTestId("parent-floating-reference")).toHaveFocus(), + ); + await userEvent.click(screen.getByTestId("parent-floating-reference")); + await waitFor(() => + expect(screen.getByTestId("child-reference")).toHaveFocus(), + ); + await userEvent.keyboard(testKbd.ESCAPE); + await waitFor(() => + expect(screen.getByTestId("parent-reference")).toHaveFocus(), + ); +}); + +it.only("places focus on an element with floating props when floating element is a wrapper", async () => { + render(FloatingWrapper); + + await userEvent.click(screen.getByRole("button")); + + await waitFor(() => expect(screen.getByTestId("inner")).toHaveFocus()); +}); diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte index 27b3a2b2..2bb74c03 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -1,52 +1,35 @@ -
- -