Skip to content

Commit 6f2f035

Browse files
committed
floating focus manager
1 parent bae10ab commit 6f2f035

File tree

3 files changed

+105
-31
lines changed

3 files changed

+105
-31
lines changed

packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte

Lines changed: 97 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
import type { FloatingRootContext } from "../../hooks/use-floating-root-context.svelte.js";
44
import type { FloatingContext } from "../../hooks/use-floating.svelte.js";
55
import { getNodeName, isHTMLElement } from "@floating-ui/utils/dom";
6-
import { getTabbableOptions } from "../../internal/tabbable.js";
6+
import {
7+
getNextTabbable,
8+
getPreviousTabbable,
9+
getTabbableOptions,
10+
isOutsideEvent,
11+
} from "../../internal/tabbable.js";
712
import { isTabbable, tabbable, type FocusableElement } from "tabbable";
813
import { isTypeableCombobox } from "../../internal/is-typeable-element.js";
914
import { markOthers, supportsInert } from "../../internal/mark-others.js";
@@ -28,7 +33,7 @@
2833
import type { Boxed, OpenChangeReason } from "../../types.js";
2934
import { useMergeRefs } from "../../hooks/use-merge-refs.svelte.js";
3035
import { watch } from "../../internal/watch.svelte.js";
31-
import { HIDDEN_STYLES_STRING } from "../focus-guard.svelte";
36+
import FocusGuard, { HIDDEN_STYLES_STRING } from "../focus-guard.svelte";
3237
3338
const LIST_LIMIT = 20;
3439
let previouslyFocusedElements: Element[] = [];
@@ -141,11 +146,16 @@
141146
outsideElementsInert?: boolean;
142147
}
143148
149+
type DismissButtonSnippetProps = {
150+
ref: Boxed<HTMLElement>;
151+
};
152+
144153
export type { FloatingFocusManagerProps };
145154
</script>
146155

147156
<script lang="ts">
148157
import VisuallyHiddenDismiss from "./visually-hidden-dismiss.svelte";
158+
import { box } from "../../internal/box.svelte.js";
149159
150160
let {
151161
context,
@@ -183,8 +193,9 @@
183193
const tree = useFloatingTree();
184194
const portalContext = usePortalContext();
185195
186-
let startDismissButton = $state<HTMLButtonElement | null>(null);
187-
let endDismissButton = $state<HTMLButtonElement | null>(null);
196+
const startDismissButtonRef = box<HTMLElement>(null!);
197+
const endDismissButtonRef = box<HTMLButtonElement>(null!);
198+
188199
let preventReturnFocus = $state(false);
189200
let isPointerDown = $state(false);
190201
let tabbableIndex = $state(-1);
@@ -378,12 +389,8 @@
378389
}
379390
});
380391
381-
let beforeGuardRef = $state<Boxed<HTMLSpanElement | null>>({
382-
current: null,
383-
});
384-
let afterGuardRef = $state<Boxed<HTMLSpanElement | null>>({
385-
current: null,
386-
});
392+
const beforeGuardRef = box<HTMLSpanElement | null>(null);
393+
const afterGuardRef = box<HTMLSpanElement | null>(null);
387394
388395
const mergedBeforeGuardRef = useMergeRefs([
389396
beforeGuardRef,
@@ -415,8 +422,8 @@
415422
context.floating,
416423
...portalNodes,
417424
...ancestorFloatingNodes,
418-
startDismissButton,
419-
endDismissButton,
425+
startDismissButtonRef,
426+
endDismissButtonRef,
420427
beforeGuardRef.current,
421428
afterGuardRef.current,
422429
portalContext?.beforeOutsideRef.current,
@@ -663,10 +670,82 @@
663670
};
664671
});
665672
666-
type DismissButtonSnippetProps = {
667-
disabled: boolean;
668-
visuallyHiddenDismiss: boolean;
669-
modal: boolean;
670-
location: "start" | "end";
671-
};
673+
const shouldRenderGuards = $derived(
674+
!disabled &&
675+
guards &&
676+
(modal ? !isUntrappedTypeableCombobox : true) &&
677+
(isInsidePortal || modal)
678+
);
672679
</script>
680+
681+
{#snippet DismissButton({ ref }: DismissButtonSnippetProps)}
682+
{#if !disabled && visuallyHiddenDismiss && modal}
683+
<VisuallyHiddenDismiss
684+
bind:ref={ref.current}
685+
onclick={(event) => context.onOpenChange(false, event)}>
686+
{typeof visuallyHiddenDismiss === "string"
687+
? visuallyHiddenDismiss
688+
: "Dismiss"}
689+
</VisuallyHiddenDismiss>
690+
{/if}
691+
{/snippet}
692+
693+
{#if shouldRenderGuards}
694+
<FocusGuard
695+
data-type="inside"
696+
bind:ref={mergedBeforeGuardRef.current}
697+
onfocus={(event) => {
698+
if (modal) {
699+
const els = getTabbableElements();
700+
enqueueFocus(
701+
order[0] === "reference" ? els[0] : els[els.length - 1]
702+
);
703+
} else if (
704+
portalContext?.preserveTabOrder &&
705+
portalContext.portalNode
706+
) {
707+
preventReturnFocus = false;
708+
if (isOutsideEvent(event, portalContext.portalNode)) {
709+
const nextTabbable =
710+
getNextTabbable() || context.domReference;
711+
nextTabbable?.focus();
712+
} else {
713+
portalContext.beforeOutsideRef.current?.focus();
714+
}
715+
}
716+
}} />
717+
{/if}
718+
<!--
719+
Ensure the first swipe is the list item. The end of the listbox popup
720+
will have a dismiss button.
721+
-->
722+
{#if !isUntrappedTypeableCombobox}
723+
{@render DismissButton({ ref: startDismissButtonRef })}
724+
{/if}
725+
{@render children?.()}
726+
{@render DismissButton({ ref: endDismissButtonRef })}
727+
{#if shouldRenderGuards}
728+
<FocusGuard
729+
data-type="inside"
730+
bind:ref={mergedAfterGuardRef.current}
731+
onfocus={(event) => {
732+
if (modal) {
733+
enqueueFocus(getTabbableElements()[0]);
734+
} else if (
735+
portalContext?.preserveTabOrder &&
736+
portalContext.portalNode
737+
) {
738+
if (closeOnFocusOut) {
739+
preventReturnFocus = true;
740+
}
741+
742+
if (isOutsideEvent(event, portalContext.portalNode)) {
743+
const prevTabbable =
744+
getPreviousTabbable() || context.domReference;
745+
prevTabbable?.focus();
746+
} else {
747+
portalContext.afterOutsideRef.current?.focus();
748+
}
749+
}
750+
}} />
751+
{/if}

packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
interface VisuallyHiddenDismissProps
77
extends HTMLButtonAttributes,
8-
WithRef<HTMLButtonElement> {}
8+
WithRef<HTMLElement> {}
99
1010
export type { VisuallyHiddenDismissProps };
1111
</script>

packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
1-
import type { ReferenceType } from "../types.js";
1+
import type { Boxed } from "../types.js";
22
import { FloatingState } from "./use-floating.svelte.js";
33

4-
interface BoxedRef {
5-
current: ReferenceType | null;
6-
}
7-
84
/**
95
* Merges the references of either floating instances or refs into a single reference
106
* that can be accessed and set via the `.current` property.
117
*/
12-
class MergeRefs {
13-
#current = $state<ReferenceType | null>(null);
8+
class MergeRefs<T extends Element = Element> {
9+
#current = $state<T | null>(null);
1410
constructor(
1511
private readonly floatingOrRef: Array<
16-
FloatingState | BoxedRef | null | undefined
12+
FloatingState | Boxed<T | null> | null | undefined
1713
>,
1814
) {}
1915

2016
get current() {
2117
return this.#current;
2218
}
2319

24-
set current(node: ReferenceType | null) {
20+
set current(node: T | null) {
2521
for (const arg of this.floatingOrRef) {
2622
if (!arg) continue;
2723
if (arg instanceof FloatingState) {
@@ -62,11 +58,10 @@ class MergeRefs {
6258
* @param floatingInstances
6359
* @returns
6460
*/
65-
function useMergeRefs(
66-
refLikes: Array<FloatingState | BoxedRef | null | undefined>,
61+
function useMergeRefs<T extends Element = Element>(
62+
refLikes: Array<FloatingState | Boxed<T | null> | null | undefined>,
6763
) {
6864
return new MergeRefs(refLikes);
6965
}
7066

7167
export { MergeRefs, useMergeRefs };
72-
export type { BoxedRef };

0 commit comments

Comments
 (0)