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.
@@ -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 onkeydown={hidden ? undefined : (ev: KeyboardEvent) => ev.key === "Escape" && (hidden = true)} bind:innerWidth bind:innerHeight />
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}
@@ -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 get called when user press ESC key
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
- oncancel?.(ev); // propagate the event to the user
31
- // if user cancels the event we don't close the modal
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
- closeModal();
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>
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowbite-svelte",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Flowbite components for Svelte",
5
5
  "main": "dist/index.js",
6
6
  "author": {
@@ -1,4 +0,0 @@
1
- import type { Action } from "svelte/action";
2
- declare const focusTrap: Action<HTMLElement>;
3
- export default focusTrap;
4
- export declare function isFocusable(element: Element): boolean;
@@ -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
- }