@winkintel/bootstrap-svelte 1.0.7 → 1.0.8
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/README.md
CHANGED
|
@@ -174,7 +174,7 @@ Interactive components are implemented as Svelte components rather than requirin
|
|
|
174
174
|
|
|
175
175
|
## Status and feedback
|
|
176
176
|
|
|
177
|
-
This package is published as `1.0.
|
|
177
|
+
This package is published as `1.0.8`, but feedback from Svelte developers is still very welcome. Useful feedback includes:
|
|
178
178
|
|
|
179
179
|
- Component API ergonomics
|
|
180
180
|
- Svelte 5 idioms and runes compatibility
|
|
@@ -21,14 +21,16 @@ The container for dropdown items.
|
|
|
21
21
|
- `isDark` (boolean): Optional. Makes the dropdown menu dark-themed, default is false.
|
|
22
22
|
- `isEnd` (boolean): Optional. Aligns the dropdown menu to the end of its parent, default is false.
|
|
23
23
|
- `offset` (number[]): Optional. Offset for the dropdown menu position, default is `[0, 2]`.
|
|
24
|
+
- `onkeydown` (EventListener): Optional. Keydown handler, called after the menu's own Escape dismissal.
|
|
24
25
|
-->
|
|
25
26
|
<script lang="ts">import { uniqueClsx } from '../common/css.js';
|
|
27
|
+
import { noop } from '../common/noop.js';
|
|
26
28
|
import { Portal } from '../index.js';
|
|
27
29
|
import { onMount } from 'svelte';
|
|
28
30
|
import { DropdownMenuState, initDropdownMenuState } from './dropdown.svelte.js';
|
|
29
31
|
// Generate a unique ID for the dropdown menu
|
|
30
32
|
const uid = $props.id();
|
|
31
|
-
let { children, class: classValues, container = false, elementRef = $bindable(null), id = `dropdown-menu-${uid}`, isDark = false, isEnd = false, offset = [0, 2], ...restOfProps } = $props();
|
|
33
|
+
let { children, class: classValues, container = false, elementRef = $bindable(null), id = `dropdown-menu-${uid}`, isDark = false, isEnd = false, offset = [0, 2], onkeydown = noop, ...restOfProps } = $props();
|
|
32
34
|
let containerElement = $derived(container === false ? 'body' : container);
|
|
33
35
|
const menuState = initDropdownMenuState({
|
|
34
36
|
get elementRef() {
|
|
@@ -63,9 +65,14 @@ $effect(() => {
|
|
|
63
65
|
$effect(() => {
|
|
64
66
|
menuState.isEnd = isEnd;
|
|
65
67
|
});
|
|
68
|
+
const handleKeydown = (event) => {
|
|
69
|
+
menuState.onkeydown(event);
|
|
70
|
+
onkeydown(event);
|
|
71
|
+
};
|
|
66
72
|
</script>
|
|
67
73
|
|
|
68
74
|
<svelte:body onclick={menuState.bodyOnclick} />
|
|
75
|
+
<svelte:window onkeyup={menuState.windowOnkeyup} />
|
|
69
76
|
|
|
70
77
|
<Portal target={containerElement} disabled={container === false}>
|
|
71
78
|
<ul
|
|
@@ -75,6 +82,7 @@ $effect(() => {
|
|
|
75
82
|
data-bs-popper={menuState.isShown && menuState.root.isNavItem ? 'static' : undefined}
|
|
76
83
|
data-popper-placement={menuState.popperPlacement}
|
|
77
84
|
{id}
|
|
85
|
+
onkeydown={handleKeydown}
|
|
78
86
|
role={menuState.root.hasItems ? 'menu' : undefined}
|
|
79
87
|
{...restOfProps}>
|
|
80
88
|
{@render children?.()}
|
|
@@ -21,6 +21,7 @@ import type { Dropdown } from './index.js';
|
|
|
21
21
|
* - `isDark` (boolean): Optional. Makes the dropdown menu dark-themed, default is false.
|
|
22
22
|
* - `isEnd` (boolean): Optional. Aligns the dropdown menu to the end of its parent, default is false.
|
|
23
23
|
* - `offset` (number[]): Optional. Offset for the dropdown menu position, default is `[0, 2]`.
|
|
24
|
+
* - `onkeydown` (EventListener): Optional. Keydown handler, called after the menu's own Escape dismissal.
|
|
24
25
|
*/
|
|
25
26
|
declare const DropdownMenu: import("svelte").Component<Dropdown.MenuProps, {}, "elementRef">;
|
|
26
27
|
type DropdownMenu = ReturnType<typeof DropdownMenu>;
|
|
@@ -28,6 +28,9 @@ import { DropdownToggleState, initDropdownToggleState } from './dropdown.svelte.
|
|
|
28
28
|
const uid = $props.id();
|
|
29
29
|
let { children, class: classValues, colorVariant, disabled = false, elementRef = $bindable(null), href, id = `dropdown-toggle-${uid}`, isSplit = false, onclick = noop, onkeydown = noop, size, type, ...restOfProps } = $props();
|
|
30
30
|
const toggleState = initDropdownToggleState({
|
|
31
|
+
get elementRef() {
|
|
32
|
+
return elementRef;
|
|
33
|
+
},
|
|
31
34
|
get id() {
|
|
32
35
|
return id;
|
|
33
36
|
},
|
|
@@ -13,7 +13,9 @@ export class DropdownRootState {
|
|
|
13
13
|
#isNavItem = $state(false);
|
|
14
14
|
#isShown = $state(false);
|
|
15
15
|
#items = $state([]);
|
|
16
|
+
#menu = null; // The menu belonging to this dropdown, registered on construction.
|
|
16
17
|
#reorderScheduled = false;
|
|
18
|
+
#toggle = null; // The toggle that controls this dropdown, registered on construction.
|
|
17
19
|
ariaLabelledBy = $state(undefined); // Used for accessibility, linking the dropdown to a label.
|
|
18
20
|
constructor(props) {
|
|
19
21
|
this.props = props;
|
|
@@ -25,6 +27,8 @@ export class DropdownRootState {
|
|
|
25
27
|
// Bind the method to ensure `this` context is correct when passed as a callback.
|
|
26
28
|
this.focusOnNextItem = this.focusOnNextItem.bind(this);
|
|
27
29
|
this.focusOnPreviousItem = this.focusOnPreviousItem.bind(this);
|
|
30
|
+
this.focusOnToggle = this.focusOnToggle.bind(this);
|
|
31
|
+
this.hide = this.hide.bind(this);
|
|
28
32
|
this.registerItem = this.registerItem.bind(this);
|
|
29
33
|
this.reorderItems = this.reorderItems.bind(this);
|
|
30
34
|
this.scheduleReorderItems = this.scheduleReorderItems.bind(this);
|
|
@@ -82,12 +86,43 @@ export class DropdownRootState {
|
|
|
82
86
|
get isShown() {
|
|
83
87
|
return this.#isShown;
|
|
84
88
|
}
|
|
89
|
+
get menu() {
|
|
90
|
+
return this.#menu;
|
|
91
|
+
}
|
|
92
|
+
set menu(menu) {
|
|
93
|
+
this.#menu = menu;
|
|
94
|
+
}
|
|
95
|
+
get toggle() {
|
|
96
|
+
return this.#toggle;
|
|
97
|
+
}
|
|
98
|
+
set toggle(toggle) {
|
|
99
|
+
this.#toggle = toggle;
|
|
100
|
+
}
|
|
101
|
+
// True when the given element is part of this dropdown — its root (toggle included)
|
|
102
|
+
// or its menu, which may be portaled outside the root via the menu's `container` prop.
|
|
103
|
+
containsElement(element) {
|
|
104
|
+
if (!element) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return Boolean(this.#elementRef?.contains(element) || this.#menu?.elementRef?.contains(element));
|
|
108
|
+
}
|
|
85
109
|
focusOnNextItem() {
|
|
86
110
|
this.focusOnItemInDirection(1);
|
|
87
111
|
}
|
|
88
112
|
focusOnPreviousItem() {
|
|
89
113
|
this.focusOnItemInDirection(-1);
|
|
90
114
|
}
|
|
115
|
+
focusOnToggle() {
|
|
116
|
+
this.#toggle?.props.elementRef?.focus();
|
|
117
|
+
}
|
|
118
|
+
// Closes the dropdown if it is open, firing the onHide/onHidden callbacks.
|
|
119
|
+
// Unlike toggleIsShown this is a no-op when the dropdown is already closed.
|
|
120
|
+
hide(event) {
|
|
121
|
+
if (!this.#isShown) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.toggleIsShown(event);
|
|
125
|
+
}
|
|
91
126
|
registerItem(item) {
|
|
92
127
|
item.itemIndex = this.#items.length; // Set the item's index based on current length.
|
|
93
128
|
this.#items = [...this.#items, item];
|
|
@@ -174,6 +209,7 @@ export class DropdownToggleState {
|
|
|
174
209
|
// Bind the method to ensure `this` context is correct when used as an event handler.
|
|
175
210
|
this.onclick = this.onclick.bind(this);
|
|
176
211
|
this.onkeydown = this.onkeydown.bind(this);
|
|
212
|
+
this.root.toggle = this; // Let the root find the toggle again, e.g. to restore focus on Escape.
|
|
177
213
|
if (this.props.isSplit && !this.root.isButtonGroup) {
|
|
178
214
|
// If the toggle is a split button, the root must be treated as a button group
|
|
179
215
|
// to ensure correct Bootstrap styling and layout.
|
|
@@ -215,7 +251,10 @@ export class DropdownMenuState {
|
|
|
215
251
|
this.destroyPopper = this.destroyPopper.bind(this);
|
|
216
252
|
this.determinePopperPlacement = this.determinePopperPlacement.bind(this);
|
|
217
253
|
this.dispose = this.dispose.bind(this);
|
|
254
|
+
this.onkeydown = this.onkeydown.bind(this);
|
|
218
255
|
this.syncIsEndWithRoot = this.syncIsEndWithRoot.bind(this);
|
|
256
|
+
this.windowOnkeyup = this.windowOnkeyup.bind(this);
|
|
257
|
+
this.root.menu = this; // Let the root reach the menu element, which may be portaled outside the root.
|
|
219
258
|
// Ensure the dropdown menu's end alignment is consistent with the root's direction.
|
|
220
259
|
this.syncIsEndWithRoot();
|
|
221
260
|
// Effect to manage the Popper.js instance lifecycle.
|
|
@@ -274,9 +313,38 @@ export class DropdownMenuState {
|
|
|
274
313
|
this.root.toggleIsShown(event);
|
|
275
314
|
}
|
|
276
315
|
}
|
|
316
|
+
// Backstop for Escape pressed on focusable content inside the menu that is not a
|
|
317
|
+
// Dropdown.Item — a form input, a plain link, a consumer's own button. Items and the
|
|
318
|
+
// toggle consume Escape before it can bubble this far, so this never double-handles.
|
|
319
|
+
onkeydown(event) {
|
|
320
|
+
if (event.key !== 'Escape') {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
handleEscape(event, this.root);
|
|
324
|
+
}
|
|
325
|
+
// Closes the dropdown once Tab has moved focus out of it. Handled on keyup rather than
|
|
326
|
+
// keydown because focus has not moved yet while the key is down, and at window level
|
|
327
|
+
// because the newly focused element is usually outside this component's markup.
|
|
328
|
+
windowOnkeyup(event) {
|
|
329
|
+
if (!this.isShown || event.key !== 'Tab') {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (this.root.containsElement(document.activeElement)) {
|
|
333
|
+
// Focus moved between items inside the dropdown, so it stays open.
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Tabbing away is an interaction outside the dropdown, so it follows the same
|
|
337
|
+
// autoClose rules as an outside click.
|
|
338
|
+
if (this.autoClose === true || this.autoClose === 'outside') {
|
|
339
|
+
this.root.hide(event);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
277
342
|
// Cleans up resources, particularly the Popper.js instance and element references.
|
|
278
343
|
dispose() {
|
|
279
344
|
this.destroyPopper();
|
|
345
|
+
if (this.root.menu === this) {
|
|
346
|
+
this.root.menu = null;
|
|
347
|
+
}
|
|
280
348
|
this.#elementRef = null;
|
|
281
349
|
this.#rootElementRef = null; // Clearing derived state source if it were directly settable.
|
|
282
350
|
this.#popperInstance = null;
|
|
@@ -449,15 +517,52 @@ export function initDropdownItemState(props) {
|
|
|
449
517
|
rootState.registerItem(itemState);
|
|
450
518
|
return itemState;
|
|
451
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Dismisses an open dropdown in response to Escape, returning focus to the toggle.
|
|
522
|
+
*
|
|
523
|
+
* Shared by the toggle, the items, and the menu itself. The menu is the backstop that
|
|
524
|
+
* catches Escape from focusable content a consumer renders inside it — inputs, links,
|
|
525
|
+
* arbitrary buttons — which are not Dropdown.Items and carry no handler of their own.
|
|
526
|
+
* Because the toggle and items stop a consumed Escape from propagating, the menu-level
|
|
527
|
+
* handler never sees the same key twice.
|
|
528
|
+
*
|
|
529
|
+
* Escape ignores autoClose: it is an explicit dismissal rather than an incidental one.
|
|
530
|
+
*
|
|
531
|
+
* @param event KeyboardEvent
|
|
532
|
+
* @param root DropdownRootState
|
|
533
|
+
*/
|
|
534
|
+
function handleEscape(event, root) {
|
|
535
|
+
if (!root.isShown) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
event.preventDefault();
|
|
539
|
+
// Stop the event here so an ancestor that also dismisses on Escape (a modal, an
|
|
540
|
+
// offcanvas, an app-level sidebar) does not close as well. Escape should unwind one
|
|
541
|
+
// layer at a time, which is what Bootstrap's own dropdown does.
|
|
542
|
+
//
|
|
543
|
+
// This covers listeners in the bubble path only. A listener bound on document or
|
|
544
|
+
// window in the *capture* phase runs before the event ever reaches the dropdown and
|
|
545
|
+
// cannot be stopped from here — notably Bootstrap's own data-API handlers, which are
|
|
546
|
+
// registered with capture. Loading Bootstrap's JS bundle alongside this package makes
|
|
547
|
+
// both implementations respond to the same markup and is not a supported setup.
|
|
548
|
+
event.stopPropagation();
|
|
549
|
+
root.hide(event);
|
|
550
|
+
root.focusOnToggle();
|
|
551
|
+
}
|
|
452
552
|
/**
|
|
453
553
|
* Handles keyboard navigation for the toggle and items within the dropdown.
|
|
454
|
-
* Supports opening the dropdown with ArrowUp/ArrowDown
|
|
554
|
+
* Supports opening the dropdown with ArrowUp/ArrowDown, navigating between items,
|
|
555
|
+
* and dismissing an open dropdown with Escape.
|
|
455
556
|
*
|
|
456
557
|
* @param event KeyboardEvent
|
|
457
558
|
* @param root DropdownRootState
|
|
458
559
|
*/
|
|
459
560
|
function handleKeydown(event, root) {
|
|
460
561
|
const { key } = event;
|
|
562
|
+
if (key === 'Escape') {
|
|
563
|
+
handleEscape(event, root);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
461
566
|
const isUpOrDownEvent = ['ArrowUp', 'ArrowDown'].includes(event.key);
|
|
462
567
|
if (isUpOrDownEvent && !root.isShown) {
|
|
463
568
|
event.preventDefault();
|
package/dist/Dropdown/types.d.ts
CHANGED