Skip to content

Commit 9e8b75c

Browse files
committed
FocusTrap.
1 parent ab25ea1 commit 9e8b75c

File tree

3 files changed

+85
-0
lines changed

3 files changed

+85
-0
lines changed

src/lib/helpers/FocusTrap.svelte

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<script lang="ts" module>
2+
interface Props {
3+
class?: string;
4+
style?: string;
5+
onblur?: () => void;
6+
onfocus?: () => void;
7+
focused?: boolean;
8+
}
9+
10+
function isDescendant(parent: HTMLElement, child?: Node) {
11+
let node: Node | null | undefined = child;
12+
while (node) {
13+
if (node == parent) {
14+
return true;
15+
}
16+
node = node.parentNode;
17+
}
18+
19+
return false;
20+
}
21+
</script>
22+
23+
<script lang="ts">
24+
let {
25+
//
26+
class: clazz,
27+
style,
28+
onblur,
29+
onfocus,
30+
focused = $bindable(false)
31+
}: Props = $props();
32+
33+
let div: HTMLElement;
34+
35+
function focusGained(e: FocusEvent) {
36+
if (focused) return; // We already HAVE focus.
37+
38+
focused = true;
39+
onfocus?.();
40+
}
41+
42+
function focusLost(e: FocusEvent) {
43+
if (!focused) return; // We've already LOST focus.
44+
45+
// @ts-ignore
46+
const target = e.relatedTarget as Node;
47+
48+
// If the newly focused element is NOT one of ours.
49+
if (!isDescendant(div, target)) {
50+
focused = false;
51+
onblur?.();
52+
}
53+
}
54+
</script>
55+
56+
<!-- svelte-ignore a11y_click_events_have_key_events -->
57+
<!-- svelte-ignore a11y_no_static_element_interactions -->
58+
<div
59+
{style}
60+
class={clazz}
61+
bind:this={div}
62+
onclick={focusGained}
63+
onfocusin={focusGained}
64+
onfocusout={focusLost}
65+
tabindex="-1"
66+
>
67+
<!-- svelte-ignore slot_element_deprecated -->
68+
<slot />
69+
</div>

src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { default as Divider } from '$lib/components/Divider.svelte';
44
export { default as DynamicList } from '$lib/components/DynamicList.svelte';
55
export { default as Input } from '$lib/components/Input.svelte';
66
export { default as InvertedScroller } from '$lib/components/InvertedScroller.svelte';
7+
export { default as FocusTrap } from '$lib/helpers/FocusTrap.svelte';
78
export { default as LongPressListener } from '$lib/helpers/LongPressListener.svelte';

src/routes/+page.svelte

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Button,
55
Divider,
66
DynamicList,
7+
FocusTrap,
78
Input,
89
InvertedScroller,
910
LongPressListener
@@ -26,6 +27,8 @@
2627
let dynamicListBleed = 300;
2728
let dynamicList1: DynamicList;
2829
let dynamicList2: DynamicList;
30+
31+
let focusTrapHasFocus: boolean = false;
2932
</script>
3033

3134
{#snippet itemRenderer(item: number | string)}
@@ -276,6 +279,18 @@ Is at bottom?
276279
{/key}
277280
</div>
278281

282+
<h2>FocusTrap</h2>
283+
284+
Has focus?
285+
<b>{focusTrapHasFocus ? 'Yes' : 'No'}</b>
286+
287+
<FocusTrap bind:focused={focusTrapHasFocus}>
288+
<Box sides={['top', 'bottom', 'left', 'right']}>
289+
<p>When you click in this box, it gains focus. When you click out, it loses focus.</p>
290+
<!-- <Input type="text" bind:value={textInputValue} placeholder="Some focusable input" /> -->
291+
</Box>
292+
</FocusTrap>
293+
279294
<h2>LongPressListener (for mobile)</h2>
280295

281296
<LongPressListener

0 commit comments

Comments
 (0)