@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.7`, but feedback from Svelte developers is still very welcome. Useful feedback includes:
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 and navigating between items.
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();
@@ -25,6 +25,7 @@ export type DropdownMenuProps = UListElement & {
25
25
  isDark?: boolean;
26
26
  isEnd?: boolean;
27
27
  offset?: PopperOffset;
28
+ onkeydown?: EventListener;
28
29
  };
29
30
  export type DropdownItemProps = AnchorElement & {
30
31
  href?: HTMLAnchorAttributes['href'];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "author": "Wink, Inc.",
3
3
  "name": "@winkintel/bootstrap-svelte",
4
- "version": "1.0.7",
4
+ "version": "1.0.8",
5
5
  "description": "Bootstrap components for Svelte 5 with TypeScript support.",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {