flowbite-svelte 1.2.3 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/drawer/Drawer.svelte +3 -2
- package/dist/modal/Modal.svelte +17 -8
- package/dist/sidebar/Sidebar.svelte +8 -6
- package/dist/utils/Popper.svelte +8 -1
- package/dist/utils/actions.svelte.d.ts +16 -0
- package/dist/utils/actions.svelte.js +114 -0
- package/package.json +1 -1
- package/dist/utils/focusTrap.d.ts +0 -4
- package/dist/utils/focusTrap.js +0 -38
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { drawer } from ".";
|
|
6
6
|
import type { DrawerProps } from "../types";
|
|
7
7
|
import clsx from "clsx";
|
|
8
|
+
import { trapFocus } from "../utils/actions.svelte";
|
|
8
9
|
|
|
9
10
|
let { children, hidden = $bindable(), closeDrawer = () => (hidden = true), activateClickOutside = true, position, width, backdrop = true, backdropClass, placement = "left", class: className, transitionParams, transitionType = fly, ...restProps }: DrawerProps = $props();
|
|
10
11
|
|
|
@@ -26,11 +27,11 @@
|
|
|
26
27
|
let transition_params = $derived(Object.assign({}, { x, y, duration: 200, easing: sineIn }));
|
|
27
28
|
</script>
|
|
28
29
|
|
|
29
|
-
<svelte:window
|
|
30
|
+
<svelte:window bind:innerWidth bind:innerHeight />
|
|
30
31
|
|
|
31
32
|
{#if !hidden}
|
|
32
33
|
<div role="presentation" class={backdropCls({ class: backdropClass })} onclick={activateClickOutside ? closeDrawer : undefined}></div>
|
|
33
|
-
<div {...restProps} class={base({ class: clsx(className) })} transition:transitionType={transitionParams ? transitionParams : (transition_params as ParamsType)} tabindex="-1">
|
|
34
|
+
<div use:trapFocus={{ onEscape: closeDrawer }} {...restProps} class={base({ class: clsx(className) })} transition:transitionType={transitionParams ? transitionParams : (transition_params as ParamsType)} tabindex="-1">
|
|
34
35
|
{@render children?.()}
|
|
35
36
|
</div>
|
|
36
37
|
{/if}
|
package/dist/modal/Modal.svelte
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
import { fade } from "svelte/transition";
|
|
7
7
|
import { modal as modalTheme } from ".";
|
|
8
8
|
import type { ModalProps } from "../types";
|
|
9
|
-
|
|
10
|
-
// TODO: missing focus trap
|
|
9
|
+
import { trapFocus } from "../utils/actions.svelte";
|
|
11
10
|
|
|
12
11
|
let { children, oncancel, onclose, modal = true, autoclose = false, header, footer, title, open = $bindable(false), permanent = false, dismissable = true, closeBtnClass, headerClass, bodyClass, footerClass, outsideclose = true, size = "md", placement, class: className, params, transition = fade, ...restProps }: ModalProps = $props();
|
|
13
12
|
|
|
@@ -25,13 +24,13 @@
|
|
|
25
24
|
};
|
|
26
25
|
|
|
27
26
|
function _oncancel(ev: Event & { currentTarget: HTMLDialogElement }) {
|
|
28
|
-
// this event
|
|
27
|
+
// this event gets called when user presses ESC key
|
|
28
|
+
// We'll handle ESC via the trapFocus action instead
|
|
29
29
|
if (ev.currentTarget instanceof HTMLDialogElement) {
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
if (ev.defaultPrevented || permanent) return;
|
|
30
|
+
// Stop the default ESC handling from dialog element
|
|
31
|
+
// as we're handling it in our trapFocus action
|
|
33
32
|
ev.preventDefault();
|
|
34
|
-
|
|
33
|
+
oncancel?.(ev); // propagate the event to the user
|
|
35
34
|
}
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -53,10 +52,20 @@
|
|
|
53
52
|
open = true;
|
|
54
53
|
}
|
|
55
54
|
});
|
|
55
|
+
|
|
56
|
+
// Handler for Escape key that respects component state
|
|
57
|
+
const handleEscape = () => {
|
|
58
|
+
if (!permanent) {
|
|
59
|
+
oncancel?.({ currentTarget: dlg } as any);
|
|
60
|
+
// If oncancel prevented default, we don't close
|
|
61
|
+
if (oncancel && event?.defaultPrevented) return;
|
|
62
|
+
closeModal();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
56
65
|
</script>
|
|
57
66
|
|
|
58
67
|
{#if open}
|
|
59
|
-
<dialog bind:this={dlg} {...restProps} class={base({ class: clsx(className) })} tabindex="-1" oncancel={_oncancel} onclick={_onclick} transition:transition={paramsOptions as ParamsType} onintrostart={() => (modal ? dlg?.showModal() : dlg?.show())} onoutroend={() => dlg?.close()}>
|
|
68
|
+
<dialog use:trapFocus={{ onEscape: handleEscape }} bind:this={dlg} {...restProps} class={base({ class: clsx(className) })} tabindex="-1" oncancel={_oncancel} onclick={_onclick} transition:transition={paramsOptions as ParamsType} onintrostart={() => (modal ? dlg?.showModal() : dlg?.show())} onoutroend={() => dlg?.close()}>
|
|
60
69
|
{#if title || header}
|
|
61
70
|
<div class={headerCls({ class: headerClass })}>
|
|
62
71
|
{#if title}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { sidebar } from ".";
|
|
7
7
|
import type { SidebarProps, SidebarCtxType } from "../types";
|
|
8
8
|
import clsx from "clsx";
|
|
9
|
+
import { trapFocus } from "../utils/actions.svelte";
|
|
9
10
|
|
|
10
11
|
let { children, isOpen = false, closeSidebar, isSingle = true, breakpoint = "md", position = "fixed", activateClickOutside = true, backdrop = true, backdropClass, transition = fly, params, divClass, ariaLabel, nonActiveClass, activeClass, activeUrl = "", class: className, ...restProps }: SidebarProps = $props();
|
|
11
12
|
|
|
@@ -20,11 +21,7 @@
|
|
|
20
21
|
let innerWidth: number = $state(-1);
|
|
21
22
|
let isLargeScreen = $derived(innerWidth >= breakpointValues[breakpoint]);
|
|
22
23
|
|
|
23
|
-
const initialPosition = position;
|
|
24
|
-
|
|
25
|
-
$effect(() => {
|
|
26
|
-
// position = isLargeScreen ? "static" : initialPosition;
|
|
27
|
-
});
|
|
24
|
+
// const initialPosition = position;
|
|
28
25
|
|
|
29
26
|
const activeUrlStore = writable("");
|
|
30
27
|
setContext("activeUrl", activeUrlStore);
|
|
@@ -50,6 +47,11 @@
|
|
|
50
47
|
let transitionParams = params ? params : { x: -320, duration: 200, easing: sineIn };
|
|
51
48
|
|
|
52
49
|
setContext("sidebarContext", sidebarCtx);
|
|
50
|
+
|
|
51
|
+
// Handler for Escape key
|
|
52
|
+
const handleEscape = () => {
|
|
53
|
+
closeSidebar?.();
|
|
54
|
+
};
|
|
53
55
|
</script>
|
|
54
56
|
|
|
55
57
|
<svelte:window bind:innerWidth />
|
|
@@ -66,7 +68,7 @@
|
|
|
66
68
|
<div role="presentation" class="fixed start-0 top-0 z-50 h-full w-full"></div>
|
|
67
69
|
{/if}
|
|
68
70
|
{/if}
|
|
69
|
-
<aside transition:transition={transitionParams} {...restProps} class={base({ class: clsx(className) })} aria-label={ariaLabel}>
|
|
71
|
+
<aside use:trapFocus={!isLargeScreen && isOpen ? { onEscape: closeSidebar ? handleEscape : undefined } : null} transition:transition={transitionParams} {...restProps} class={base({ class: clsx(className) })} aria-label={ariaLabel}>
|
|
70
72
|
<div class={div({ class: divClass })}>
|
|
71
73
|
{@render children()}
|
|
72
74
|
</div>
|
package/dist/utils/Popper.svelte
CHANGED
|
@@ -99,10 +99,11 @@
|
|
|
99
99
|
if (ev.newState === "open") {
|
|
100
100
|
autoUpdateDestroy = dom.autoUpdate(referenceElement ?? invoker, popover, updatePopoverPosition);
|
|
101
101
|
popover.ownerDocument.addEventListener("click", closeOnClickOutside);
|
|
102
|
+
popover.ownerDocument.addEventListener("keydown", closeOnEscape); // ✅ Add this line
|
|
102
103
|
} else {
|
|
103
|
-
// When closing the popover, we destroy the autoUpdate instance
|
|
104
104
|
autoUpdateDestroy();
|
|
105
105
|
popover.ownerDocument.removeEventListener("click", closeOnClickOutside);
|
|
106
|
+
popover.ownerDocument.removeEventListener("keydown", closeOnEscape); // ✅ Add this line
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -150,6 +151,12 @@
|
|
|
150
151
|
});
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
function closeOnEscape(event: KeyboardEvent) {
|
|
155
|
+
if (event.key === "Escape") {
|
|
156
|
+
isOpen = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
153
160
|
/**
|
|
154
161
|
* Close the popper when clicking outside of it.
|
|
155
162
|
* This is necessary to get around a bug in Safari where clicking outside of the open popper does not close it.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte action that traps focus within a DOM node and handles Escape key
|
|
3
|
+
* @param node - The DOM node to trap focus within
|
|
4
|
+
* @param options - Optional configuration object
|
|
5
|
+
* @returns An action object with destroy method
|
|
6
|
+
*/
|
|
7
|
+
export declare function trapFocus(node: HTMLElement, options?: {
|
|
8
|
+
onEscape?: () => void;
|
|
9
|
+
isClosing?: boolean;
|
|
10
|
+
} | null): {
|
|
11
|
+
update(newOptions?: {
|
|
12
|
+
onEscape?: () => void;
|
|
13
|
+
isClosing?: boolean;
|
|
14
|
+
} | null): void;
|
|
15
|
+
destroy(): void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte action that traps focus within a DOM node and handles Escape key
|
|
3
|
+
* @param node - The DOM node to trap focus within
|
|
4
|
+
* @param options - Optional configuration object
|
|
5
|
+
* @returns An action object with destroy method
|
|
6
|
+
*/
|
|
7
|
+
export function trapFocus(node, options = {}) {
|
|
8
|
+
// If options is null, don't trap focus at all
|
|
9
|
+
if (options === null) {
|
|
10
|
+
return {
|
|
11
|
+
update(newOptions = {}) {
|
|
12
|
+
options = newOptions;
|
|
13
|
+
},
|
|
14
|
+
destroy() { }
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const previous = document.activeElement;
|
|
18
|
+
// Track if we're currently closing via outside click
|
|
19
|
+
let isClosingViaOutsideClick = false;
|
|
20
|
+
// Create a flag to prevent re-focusing when focus is moved outside
|
|
21
|
+
let isFocusMovedOutside = false;
|
|
22
|
+
function focusable() {
|
|
23
|
+
return Array.from(node.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'));
|
|
24
|
+
}
|
|
25
|
+
function handleKeydown(event) {
|
|
26
|
+
if (event.key === "Tab" && options !== null) {
|
|
27
|
+
const current = document.activeElement;
|
|
28
|
+
const elements = focusable();
|
|
29
|
+
const first = elements.at(0);
|
|
30
|
+
const last = elements.at(-1);
|
|
31
|
+
if (event.shiftKey && current === first) {
|
|
32
|
+
last?.focus();
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
}
|
|
35
|
+
if (!event.shiftKey && current === last) {
|
|
36
|
+
first?.focus();
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (event.key === "Escape" && options !== null && options.onEscape) {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
// Mark as closing via escape to prevent focus restoration
|
|
43
|
+
isClosingViaOutsideClick = true;
|
|
44
|
+
options.onEscape();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Handler for when focus moves outside the trapped area
|
|
48
|
+
function handleFocusOut(event) {
|
|
49
|
+
// If focus is moving outside our node and not to one of our triggers
|
|
50
|
+
if (!node.contains(event.relatedTarget) && event.relatedTarget !== previous) {
|
|
51
|
+
isFocusMovedOutside = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
$effect(() => {
|
|
55
|
+
// Only add event listeners if options is not null
|
|
56
|
+
if (options !== null) {
|
|
57
|
+
// Check if we're currently in a closing state
|
|
58
|
+
isClosingViaOutsideClick = !!options.isClosing;
|
|
59
|
+
// Only auto-focus if not closing from outside click
|
|
60
|
+
if (!isClosingViaOutsideClick && !isFocusMovedOutside) {
|
|
61
|
+
const elements = focusable();
|
|
62
|
+
if (elements.length > 0) {
|
|
63
|
+
elements[0].focus();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
node.addEventListener("keydown", handleKeydown);
|
|
67
|
+
node.addEventListener("focusout", handleFocusOut);
|
|
68
|
+
return () => {
|
|
69
|
+
node.removeEventListener("keydown", handleKeydown);
|
|
70
|
+
node.removeEventListener("focusout", handleFocusOut);
|
|
71
|
+
// Only restore focus if not closing via outside click and focus hasn't moved outside
|
|
72
|
+
if (!isClosingViaOutsideClick && !isFocusMovedOutside && previous) {
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
previous.focus({ preventScroll: true });
|
|
75
|
+
}, 0);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
// Return the action object with destroy method
|
|
81
|
+
return {
|
|
82
|
+
update(newOptions = {}) {
|
|
83
|
+
// Update the closing state
|
|
84
|
+
if (newOptions && newOptions.isClosing !== undefined) {
|
|
85
|
+
isClosingViaOutsideClick = newOptions.isClosing;
|
|
86
|
+
}
|
|
87
|
+
options = newOptions;
|
|
88
|
+
// Clean up existing listeners if options becomes null
|
|
89
|
+
if (options === null) {
|
|
90
|
+
node.removeEventListener("keydown", handleKeydown);
|
|
91
|
+
node.removeEventListener("focusout", handleFocusOut);
|
|
92
|
+
}
|
|
93
|
+
else if (options !== null) {
|
|
94
|
+
// Add listener if it wasn't already there
|
|
95
|
+
node.removeEventListener("keydown", handleKeydown);
|
|
96
|
+
node.removeEventListener("focusout", handleFocusOut);
|
|
97
|
+
node.addEventListener("keydown", handleKeydown);
|
|
98
|
+
node.addEventListener("focusout", handleFocusOut);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
destroy() {
|
|
102
|
+
if (options !== null) {
|
|
103
|
+
node.removeEventListener("keydown", handleKeydown);
|
|
104
|
+
node.removeEventListener("focusout", handleFocusOut);
|
|
105
|
+
// Only restore focus if not closing via outside click and focus hasn't moved outside
|
|
106
|
+
if (!isClosingViaOutsideClick && !isFocusMovedOutside && previous) {
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
previous.focus({ preventScroll: true });
|
|
109
|
+
}, 0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
package/package.json
CHANGED
package/dist/utils/focusTrap.js
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Taken from github.com/carbon-design-system/carbon/packages/react/src/internal/keyboard/navigation.js
|
|
3
|
-
//
|
|
4
|
-
const selectorTabbable = `
|
|
5
|
-
a[href], area[href], input:not([disabled]):not([tabindex='-1']),
|
|
6
|
-
button:not([disabled]):not([tabindex='-1']),select:not([disabled]):not([tabindex='-1']),
|
|
7
|
-
textarea:not([disabled]):not([tabindex='-1']),
|
|
8
|
-
iframe, object, embed, *[tabindex]:not([tabindex='-1']):not([disabled]), *[contenteditable=true]
|
|
9
|
-
`;
|
|
10
|
-
const focusTrap = (node) => {
|
|
11
|
-
const handleFocusTrap = (e) => {
|
|
12
|
-
const isTabPressed = e.key === "Tab" || e.keyCode === 9;
|
|
13
|
-
if (!isTabPressed) {
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
const tabbable = Array.from(node.querySelectorAll(selectorTabbable)).filter((el) => el instanceof HTMLElement && el.hidden !== true);
|
|
17
|
-
let index = tabbable.indexOf(node.ownerDocument.activeElement);
|
|
18
|
-
if (index === -1 && e.shiftKey) {
|
|
19
|
-
index = 0;
|
|
20
|
-
}
|
|
21
|
-
index += tabbable.length + (e.shiftKey ? -1 : 1);
|
|
22
|
-
index %= tabbable.length;
|
|
23
|
-
tabbable[index].focus();
|
|
24
|
-
e.preventDefault();
|
|
25
|
-
};
|
|
26
|
-
node.ownerDocument.addEventListener("keydown", handleFocusTrap, true);
|
|
27
|
-
return {
|
|
28
|
-
destroy() {
|
|
29
|
-
node.ownerDocument.removeEventListener("keydown", handleFocusTrap, true);
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
};
|
|
33
|
-
export default focusTrap;
|
|
34
|
-
export function isFocusable(element) {
|
|
35
|
-
if (!element)
|
|
36
|
-
return false;
|
|
37
|
-
return element.matches(selectorTabbable);
|
|
38
|
-
}
|