@ionic/core 8.8.7-dev.11779385275.161a641b → 8.8.7-dev.11779467048.1641d05e

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.
Files changed (72) hide show
  1. package/components/index.js +1 -1
  2. package/components/ion-action-sheet.js +1 -1
  3. package/components/ion-alert.js +1 -1
  4. package/components/ion-datetime.js +1 -1
  5. package/components/ion-gallery.js +1 -1
  6. package/components/ion-loading.js +1 -1
  7. package/components/ion-menu.js +1 -1
  8. package/components/ion-modal.js +1 -1
  9. package/components/ion-picker-legacy.js +1 -1
  10. package/components/ion-popover.js +1 -1
  11. package/components/ion-select-modal.js +1 -1
  12. package/components/ion-select-popover.js +1 -1
  13. package/components/ion-select.js +1 -1
  14. package/components/ion-toast.js +1 -1
  15. package/components/{p-B2rpt1JV.js → p-C38HUpU5.js} +1 -1
  16. package/components/{p-Dmuy6xyk.js → p-C4G6C9fP.js} +1 -1
  17. package/components/{p-B6zr9RZN.js → p-CVRxImH6.js} +1 -1
  18. package/components/{p-B71c6yUH.js → p-CoFDGTFO.js} +1 -1
  19. package/components/{p-DAv9P_LE.js → p-CykCvfXQ.js} +1 -1
  20. package/components/p-DHTe6lDL.js +4 -0
  21. package/components/{p-Di5rHO3q.js → p-qZr7hBPz.js} +1 -1
  22. package/dist/cjs/index.cjs.js +1 -1
  23. package/dist/cjs/ion-action-sheet.cjs.entry.js +1 -1
  24. package/dist/cjs/ion-alert.cjs.entry.js +1 -1
  25. package/dist/cjs/ion-datetime_3.cjs.entry.js +1 -1
  26. package/dist/cjs/ion-gallery.cjs.entry.js +7 -28
  27. package/dist/cjs/ion-loading.cjs.entry.js +1 -1
  28. package/dist/cjs/ion-menu_3.cjs.entry.js +1 -1
  29. package/dist/cjs/ion-modal.cjs.entry.js +1 -1
  30. package/dist/cjs/ion-popover.cjs.entry.js +1 -1
  31. package/dist/cjs/ion-select-modal.cjs.entry.js +1 -1
  32. package/dist/cjs/ion-select_3.cjs.entry.js +1 -1
  33. package/dist/cjs/ion-toast.cjs.entry.js +1 -1
  34. package/dist/cjs/{overlays-Hci_7vw_.js → overlays-C54DhaTC.js} +187 -10
  35. package/dist/collection/components/gallery/gallery.js +7 -28
  36. package/dist/collection/utils/overlays.js +187 -10
  37. package/dist/docs.json +1 -1
  38. package/dist/esm/index.js +1 -1
  39. package/dist/esm/ion-action-sheet.entry.js +1 -1
  40. package/dist/esm/ion-alert.entry.js +1 -1
  41. package/dist/esm/ion-datetime_3.entry.js +1 -1
  42. package/dist/esm/ion-gallery.entry.js +7 -28
  43. package/dist/esm/ion-loading.entry.js +1 -1
  44. package/dist/esm/ion-menu_3.entry.js +1 -1
  45. package/dist/esm/ion-modal.entry.js +1 -1
  46. package/dist/esm/ion-popover.entry.js +1 -1
  47. package/dist/esm/ion-select-modal.entry.js +1 -1
  48. package/dist/esm/ion-select_3.entry.js +1 -1
  49. package/dist/esm/ion-toast.entry.js +1 -1
  50. package/dist/esm/{overlays-rwDDzEs4.js → overlays-ttYCMKRp.js} +187 -10
  51. package/dist/ionic/index.esm.js +1 -1
  52. package/dist/ionic/ionic.esm.js +1 -1
  53. package/dist/ionic/p-06bd033b.entry.js +4 -0
  54. package/dist/ionic/{p-c10fa162.entry.js → p-1f74b8d4.entry.js} +1 -1
  55. package/dist/ionic/{p-a9fb086b.entry.js → p-2f8aa0ac.entry.js} +1 -1
  56. package/dist/ionic/{p-2f0073af.entry.js → p-3331cfa9.entry.js} +1 -1
  57. package/dist/ionic/{p-35b144f5.entry.js → p-33c34361.entry.js} +1 -1
  58. package/dist/ionic/{p-15e3e8f5.entry.js → p-5061a8d4.entry.js} +1 -1
  59. package/dist/ionic/{p-4a0260e6.entry.js → p-8f04bd89.entry.js} +1 -1
  60. package/dist/ionic/{p-bf972309.entry.js → p-967576f8.entry.js} +1 -1
  61. package/dist/ionic/p-DdyNaGpi.js +4 -0
  62. package/dist/ionic/{p-71b6014c.entry.js → p-bb898d47.entry.js} +1 -1
  63. package/dist/ionic/p-dea52cb3.entry.js +4 -0
  64. package/dist/ionic/{p-432c5888.entry.js → p-fc796d48.entry.js} +1 -1
  65. package/dist/types/components/gallery/gallery.d.ts +2 -5
  66. package/hydrate/index.js +194 -38
  67. package/hydrate/index.mjs +194 -38
  68. package/package.json +1 -1
  69. package/components/p-CtiqM786.js +0 -4
  70. package/dist/ionic/p-0f3b4262.entry.js +0 -4
  71. package/dist/ionic/p-4079cee3.entry.js +0 -4
  72. package/dist/ionic/p-C4uUM9DM.js +0 -4
@@ -104,8 +104,101 @@ const focusElementInContext = (hostToFocus, fallbackElement) => {
104
104
  let lastOverlayIndex = 0;
105
105
  let lastId = 0;
106
106
  const activeAnimations = new WeakMap();
107
+ const OVERLAY_FOCUS_TRAP_SELECTOR = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover';
108
+ const ION_SELECT_MODAL_SELECTOR = 'ion-select-modal';
109
+ const isSelectModalOptionControl = (el) => el.tagName === 'ION-RADIO' || el.tagName === 'ION-CHECKBOX';
107
110
  /**
108
- * Determines if the overlay's backdrop is always blocking (no background interaction).
111
+ * Returns the currently focused element for keyboard focus checks.
112
+ *
113
+ * Starts from `document.activeElement` (non-shadow / light DOM focus).
114
+ * If focus is inside one or more open shadow roots
115
+ * (e.g. native control inside `ion-radio`), walks through nested
116
+ * `shadowRoot.activeElement` values until the innermost focused node is reached.
117
+ */
118
+ const getActiveElement = (ownerDoc) => {
119
+ var _a;
120
+ let active = ownerDoc.activeElement;
121
+ if (!active) {
122
+ return null;
123
+ }
124
+ while ((_a = active.shadowRoot) === null || _a === void 0 ? void 0 : _a.activeElement) {
125
+ active = active.shadowRoot.activeElement;
126
+ }
127
+ return active;
128
+ };
129
+ /**
130
+ * Walks from a focused node (possibly deep inside shadow roots)
131
+ * up to the nearest `ion-radio` / `ion-checkbox` host.
132
+ */
133
+ const getOptionControlHost = (active) => {
134
+ let n = active;
135
+ while (n) {
136
+ if (isSelectModalOptionControl(n)) {
137
+ return n;
138
+ }
139
+ const root = n.getRootNode();
140
+ if (root instanceof ShadowRoot && root.host instanceof HTMLElement) {
141
+ n = root.host;
142
+ }
143
+ else {
144
+ return null;
145
+ }
146
+ }
147
+ return null;
148
+ };
149
+ /**
150
+ * Sheet modals can have visual order that differs from DOM order.
151
+ * Without sorting, Tab can skip the handle after option traversal.
152
+ *
153
+ * Order: option controls (radio/checkbox) → sheet handle → end-slot
154
+ * header button. Any other focusables are appended after.
155
+ */
156
+ const sortSheetModalFocusables = (overlay, elements) => {
157
+ const optionControls = elements.filter((el) => {
158
+ return overlay.contains(el) && isSelectModalOptionControl(el) && el.tabIndex >= 0;
159
+ });
160
+ const cancelControl = elements.find((el) => overlay.contains(el) &&
161
+ el.tagName === 'ION-BUTTON' &&
162
+ el.closest('ion-header ion-buttons[slot="end"]') !== null);
163
+ const handleControl = elements.find((el) => el.classList.contains('modal-handle'));
164
+ const sortByGeometry = (els) => [...els].sort((a, b) => {
165
+ const ra = a.getBoundingClientRect();
166
+ const rb = b.getBoundingClientRect();
167
+ const topDiff = ra.top - rb.top;
168
+ if (Math.abs(topDiff) > 1) {
169
+ return topDiff;
170
+ }
171
+ return ra.left - rb.left;
172
+ });
173
+ const ordered = [];
174
+ ordered.push(...sortByGeometry(optionControls));
175
+ if (handleControl) {
176
+ ordered.push(handleControl);
177
+ }
178
+ if (cancelControl) {
179
+ ordered.push(cancelControl);
180
+ }
181
+ const used = new Set(ordered);
182
+ for (const el of elements) {
183
+ if (!used.has(el)) {
184
+ ordered.push(el);
185
+ }
186
+ }
187
+ return ordered;
188
+ };
189
+ /**
190
+ * Option controls in groups use a tabindex pattern where only one
191
+ * option is tabbable (`tabIndex="0"`) while the others are `-1`.
192
+ * This returns the index of that current tabbable option.
193
+ */
194
+ const getTabbableOptionControlIndex = (elements, overlay) => {
195
+ return elements.findIndex((el) => {
196
+ return overlay.contains(el) && isSelectModalOptionControl(el) && el.tabIndex >= 0;
197
+ });
198
+ };
199
+ /**
200
+ * Determines if the overlay's backdrop is always blocking
201
+ * (no background interaction).
109
202
  * Returns false if showBackdrop=false or backdropBreakpoint > 0.
110
203
  */
111
204
  const isBackdropAlwaysBlocking = (el) => {
@@ -226,7 +319,7 @@ const focusElementInOverlay = (hostToFocus, overlay) => {
226
319
  * Should NOT include: Toast
227
320
  */
228
321
  const trapKeyboardFocus = (ev, doc) => {
229
- const lastOverlay = getPresentedOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover');
322
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
230
323
  const target = ev.target;
231
324
  /**
232
325
  * If no active overlay, ignore this event.
@@ -409,6 +502,30 @@ const connectListeners = (doc) => {
409
502
  doc.addEventListener('focus', (ev) => {
410
503
  trapKeyboardFocus(ev, doc);
411
504
  }, true);
505
+ /**
506
+ * Remember which option control last received focus
507
+ * (arrows, click, or Tab). This pattern keeps `tabIndex=0` on the
508
+ * checked/first radio, so the Tab trap uses this when wrapping back
509
+ * into the list or focusing the option-group slot.
510
+ */
511
+ doc.addEventListener('focusin', (ev) => {
512
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
513
+ if (!lastOverlay || lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS)) {
514
+ return;
515
+ }
516
+ const isSheetModal = lastOverlay.classList.contains('modal-sheet');
517
+ if (!isSheetModal) {
518
+ return;
519
+ }
520
+ const target = ev.target;
521
+ if (!(target instanceof HTMLElement)) {
522
+ return;
523
+ }
524
+ const optionHost = getOptionControlHost(target);
525
+ if (optionHost && lastOverlay.contains(optionHost)) {
526
+ lastOverlay.trapLastSheetOptionControl = optionHost;
527
+ }
528
+ }, true);
412
529
  // Listen for keydown events to intercept Tab navigation.
413
530
  // This is needed for Safari and Firefox which may skip focusable
414
531
  // elements or allow focus to escape the overlay.
@@ -418,10 +535,10 @@ const connectListeners = (doc) => {
418
535
  var _a, _b, _c;
419
536
  if (ev.key !== 'Tab' && ev.key !== 'Alt+Tab')
420
537
  return;
421
- const lastOverlay = getPresentedOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover');
538
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
422
539
  if (!lastOverlay || lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS))
423
540
  return;
424
- const activeElement = doc.activeElement;
541
+ const activeElement = getActiveElement(doc);
425
542
  if (activeElement === lastOverlay) {
426
543
  ev.preventDefault();
427
544
  focusFirstDescendant(lastOverlay);
@@ -437,16 +554,32 @@ const connectListeners = (doc) => {
437
554
  if (!isInsideOverlay)
438
555
  return;
439
556
  // Get all focusable elements from both light and shadow DOM
440
- const allFocusable = [
557
+ let allFocusable = [
441
558
  ...lastOverlay.querySelectorAll(focusableQueryString),
442
559
  ...(((_c = lastOverlay.shadowRoot) === null || _c === void 0 ? void 0 : _c.querySelectorAll(focusableQueryString)) || []),
443
560
  ];
561
+ const selectModalEl = lastOverlay.querySelector(ION_SELECT_MODAL_SELECTOR);
562
+ const isSheetModal = lastOverlay.classList.contains('modal-sheet');
563
+ /**
564
+ * Some sheet modal content, including ion-select-modal,
565
+ * renders option containers as `ion-item.select-interface-option`.
566
+ * These can match focusable selectors in some builds but
567
+ * are not intended tab stops. Keep only true interactive
568
+ * controls so Tab can move from options to header
569
+ * controls/handle.
570
+ */
571
+ if (selectModalEl) {
572
+ allFocusable = allFocusable.filter((el) => !el.matches('ion-item.select-interface-option'));
573
+ }
574
+ if (isSheetModal) {
575
+ allFocusable = sortSheetModalFocusables(lastOverlay, allFocusable);
576
+ }
444
577
  if (allFocusable.length === 0) {
445
578
  ev.preventDefault();
446
579
  return;
447
580
  }
448
581
  // Find current element's index (accounting for shadow DOM)
449
- const currentIndex = activeElement
582
+ let currentIndex = activeElement
450
583
  ? allFocusable.findIndex((el) => {
451
584
  var _a;
452
585
  if (el === activeElement)
@@ -457,6 +590,31 @@ const connectListeners = (doc) => {
457
590
  return rootNode instanceof ShadowRoot && rootNode.host === el;
458
591
  })
459
592
  : -1;
593
+ /**
594
+ * Radio/checkbox groups can move focus onto an option with
595
+ * `tabIndex=-1`, while another option still has `tabIndex=0`
596
+ * in the list. `findIndex` then yields -1 and Tab incorrectly
597
+ * wraps to `allFocusable[0]`.
598
+ * Treat focus on any option control inside the sheet modal
599
+ * as the same trap slot as the listed tabbable option
600
+ * (`tabIndex >= 0`) so Tab goes handle → Cancel, not back
601
+ * to the first radio.
602
+ */
603
+ if (currentIndex < 0 && isSheetModal && activeElement) {
604
+ const optionHost = getOptionControlHost(activeElement);
605
+ if (optionHost && lastOverlay.contains(optionHost)) {
606
+ const directIndex = allFocusable.indexOf(optionHost);
607
+ if (directIndex >= 0) {
608
+ currentIndex = directIndex;
609
+ }
610
+ else {
611
+ const tabbableOptionIndex = getTabbableOptionControlIndex(allFocusable, lastOverlay);
612
+ if (tabbableOptionIndex >= 0) {
613
+ currentIndex = tabbableOptionIndex;
614
+ }
615
+ }
616
+ }
617
+ }
460
618
  ev.preventDefault();
461
619
  // Helper to focus an element, handling shadow DOM properly
462
620
  const focusElement = (element) => {
@@ -470,24 +628,43 @@ const connectListeners = (doc) => {
470
628
  }
471
629
  helpers.focusVisibleElement(element);
472
630
  };
631
+ let nextIndex;
473
632
  if (ev.shiftKey) {
474
633
  // Shift+Tab: previous element, wrap to last if at first
475
634
  if (currentIndex <= 0) {
476
- focusLastDescendant(lastOverlay);
635
+ nextIndex = allFocusable.length - 1;
477
636
  }
478
637
  else {
479
- focusElement(allFocusable[currentIndex - 1]);
638
+ nextIndex = currentIndex - 1;
480
639
  }
481
640
  }
482
641
  else {
483
642
  // Tab: next element, wrap to first if at last
484
643
  if (currentIndex < 0 || currentIndex >= allFocusable.length - 1) {
485
- focusFirstDescendant(lastOverlay);
644
+ nextIndex = 0;
486
645
  }
487
646
  else {
488
- focusElement(allFocusable[currentIndex + 1]);
647
+ nextIndex = currentIndex + 1;
648
+ }
649
+ }
650
+ const nextEl = allFocusable[nextIndex];
651
+ const overlayTrap = lastOverlay;
652
+ const tabbableOptionIndex = isSheetModal ? getTabbableOptionControlIndex(allFocusable, lastOverlay) : -1;
653
+ /**
654
+ * The trap list only includes one tabbable option host
655
+ * (`tabIndex >= 0`), usually the checked/first radio.
656
+ * `focusin` tracks the real last-focused option; use it
657
+ * whenever Tab would focus that slot (wrap from Cancel,
658
+ * Shift+Tab from handle, etc.).
659
+ */
660
+ let focusTarget = nextEl;
661
+ if (isSheetModal && tabbableOptionIndex >= 0 && nextIndex === tabbableOptionIndex) {
662
+ const saved = overlayTrap.trapLastSheetOptionControl;
663
+ if ((saved === null || saved === void 0 ? void 0 : saved.isConnected) && lastOverlay.contains(saved) && saved !== nextEl) {
664
+ focusTarget = saved;
489
665
  }
490
666
  }
667
+ focusElement(focusTarget);
491
668
  }, true);
492
669
  // handle back-button click
493
670
  doc.addEventListener('ionBackButton', (ev) => {
@@ -25,7 +25,6 @@ const BREAKPOINT_ORDER = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'];
25
25
  */
26
26
  export class Gallery {
27
27
  constructor() {
28
- this.itemWrapperSelector = '[data-gallery-group]';
29
28
  // Keep track of whether we've warned about invalid columns, invalid gap,
30
29
  // and unused order properties to avoid duplicate warnings on screen resize.
31
30
  this.hasWarnedInvalidColumns = false;
@@ -80,9 +79,8 @@ export class Gallery {
80
79
  const styles = getComputedStyle(this.el);
81
80
  const rowHeight = parseFloat(styles.getPropertyValue('grid-auto-rows')) || 0;
82
81
  const rowGap = parseFloat(styles.getPropertyValue('row-gap')) || parseFloat(styles.getPropertyValue('gap')) || 0;
83
- const itemGap = parseFloat(styles.getPropertyValue('column-gap')) || parseFloat(styles.getPropertyValue('gap')) || 0;
84
82
  const items = this.getItems();
85
- this.layoutMasonry(items, rowHeight, rowGap, itemGap, columns);
83
+ this.layoutMasonry(items, rowHeight, rowGap, columns);
86
84
  };
87
85
  }
88
86
  onColumnsOrGapChanged() {
@@ -339,28 +337,12 @@ export class Gallery {
339
337
  const gap = this.getGapForWidth(width);
340
338
  this.el.style.setProperty('--internal-gallery-gap', `${gap}`);
341
339
  }
342
- isGalleryItemElement(element) {
343
- var _a;
344
- return typeof ((_a = element.style) === null || _a === void 0 ? void 0 : _a.setProperty) === 'function';
345
- }
346
340
  /**
347
- * Return all gallery items that can be grid items with inline placement styles.
348
- * Direct children marked with `data-gallery-group` are ignored and replaced
349
- * with their element children.
341
+ * Return all directly slotted children of the gallery that can be grid items
342
+ * with inline placement styles (HTML elements and SVG elements).
350
343
  */
351
344
  getItems() {
352
- const items = Array.from(this.el.children).filter((child) => this.isGalleryItemElement(child));
353
- const flattenedItems = [];
354
- items.forEach((itemEl) => {
355
- if (!itemEl.matches(this.itemWrapperSelector)) {
356
- flattenedItems.push(itemEl);
357
- return;
358
- }
359
- itemEl.style.display = 'contents';
360
- const wrappedItems = Array.from(itemEl.children).filter((child) => this.isGalleryItemElement(child));
361
- flattenedItems.push(...wrappedItems);
362
- });
363
- return flattenedItems;
345
+ return Array.from(this.el.children).filter((child) => { var _a; return typeof ((_a = child.style) === null || _a === void 0 ? void 0 : _a.setProperty) === 'function'; });
364
346
  }
365
347
  /**
366
348
  * Clear the item styles for the given item element.
@@ -417,14 +399,11 @@ export class Gallery {
417
399
  /**
418
400
  * Apply masonry placement by assigning each item a column and row span.
419
401
  */
420
- layoutMasonry(items, rowHeight, rowGap, itemGap, columns) {
402
+ layoutMasonry(items, rowHeight, rowGap, columns) {
421
403
  const columnHeights = new Array(columns).fill(0);
422
404
  const lastItemsByColumn = new Array(columns).fill(undefined);
423
405
  items.forEach((itemEl, i) => {
424
406
  itemEl.style.marginBottom = '';
425
- if (itemEl.parentElement !== this.el) {
426
- itemEl.style.marginBottom = `${itemGap}px`;
427
- }
428
407
  const span = this.calculateRowSpan(itemEl, rowHeight, rowGap);
429
408
  if (span === undefined) {
430
409
  this.clearItemStyles(itemEl);
@@ -473,11 +452,11 @@ export class Gallery {
473
452
  const { layout } = this;
474
453
  const order = this.getOrder();
475
454
  const theme = getIonTheme(this);
476
- return (h(Host, { key: '10b550a9cc0c6b6994a86ec95bc6dbfadb3e8c58', class: {
455
+ return (h(Host, { key: '1bf2973d22835c0dbddf3214b602f8c08b95e421', class: {
477
456
  [theme]: true,
478
457
  [`gallery-layout-${layout}`]: true,
479
458
  [`gallery-order-${order}`]: layout === 'masonry' && order !== undefined,
480
- } }, h("slot", { key: '1ac472f867053973aa90975cd61901a2e8ff20aa', onSlotchange: this.onSlotChange })));
459
+ } }, h("slot", { key: '0dea31f609f6afdb1d73ecb2d873909ffe49203f', onSlotchange: this.onSlotChange })));
481
460
  }
482
461
  static get is() { return "ion-gallery"; }
483
462
  static get encapsulation() { return "shadow"; }
@@ -14,8 +14,101 @@ import { addEventListener, componentOnReady, focusVisibleElement, getElementRoot
14
14
  let lastOverlayIndex = 0;
15
15
  let lastId = 0;
16
16
  export const activeAnimations = new WeakMap();
17
+ const OVERLAY_FOCUS_TRAP_SELECTOR = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover';
18
+ const ION_SELECT_MODAL_SELECTOR = 'ion-select-modal';
19
+ const isSelectModalOptionControl = (el) => el.tagName === 'ION-RADIO' || el.tagName === 'ION-CHECKBOX';
17
20
  /**
18
- * Determines if the overlay's backdrop is always blocking (no background interaction).
21
+ * Returns the currently focused element for keyboard focus checks.
22
+ *
23
+ * Starts from `document.activeElement` (non-shadow / light DOM focus).
24
+ * If focus is inside one or more open shadow roots
25
+ * (e.g. native control inside `ion-radio`), walks through nested
26
+ * `shadowRoot.activeElement` values until the innermost focused node is reached.
27
+ */
28
+ const getActiveElement = (ownerDoc) => {
29
+ var _a;
30
+ let active = ownerDoc.activeElement;
31
+ if (!active) {
32
+ return null;
33
+ }
34
+ while ((_a = active.shadowRoot) === null || _a === void 0 ? void 0 : _a.activeElement) {
35
+ active = active.shadowRoot.activeElement;
36
+ }
37
+ return active;
38
+ };
39
+ /**
40
+ * Walks from a focused node (possibly deep inside shadow roots)
41
+ * up to the nearest `ion-radio` / `ion-checkbox` host.
42
+ */
43
+ const getOptionControlHost = (active) => {
44
+ let n = active;
45
+ while (n) {
46
+ if (isSelectModalOptionControl(n)) {
47
+ return n;
48
+ }
49
+ const root = n.getRootNode();
50
+ if (root instanceof ShadowRoot && root.host instanceof HTMLElement) {
51
+ n = root.host;
52
+ }
53
+ else {
54
+ return null;
55
+ }
56
+ }
57
+ return null;
58
+ };
59
+ /**
60
+ * Sheet modals can have visual order that differs from DOM order.
61
+ * Without sorting, Tab can skip the handle after option traversal.
62
+ *
63
+ * Order: option controls (radio/checkbox) → sheet handle → end-slot
64
+ * header button. Any other focusables are appended after.
65
+ */
66
+ const sortSheetModalFocusables = (overlay, elements) => {
67
+ const optionControls = elements.filter((el) => {
68
+ return overlay.contains(el) && isSelectModalOptionControl(el) && el.tabIndex >= 0;
69
+ });
70
+ const cancelControl = elements.find((el) => overlay.contains(el) &&
71
+ el.tagName === 'ION-BUTTON' &&
72
+ el.closest('ion-header ion-buttons[slot="end"]') !== null);
73
+ const handleControl = elements.find((el) => el.classList.contains('modal-handle'));
74
+ const sortByGeometry = (els) => [...els].sort((a, b) => {
75
+ const ra = a.getBoundingClientRect();
76
+ const rb = b.getBoundingClientRect();
77
+ const topDiff = ra.top - rb.top;
78
+ if (Math.abs(topDiff) > 1) {
79
+ return topDiff;
80
+ }
81
+ return ra.left - rb.left;
82
+ });
83
+ const ordered = [];
84
+ ordered.push(...sortByGeometry(optionControls));
85
+ if (handleControl) {
86
+ ordered.push(handleControl);
87
+ }
88
+ if (cancelControl) {
89
+ ordered.push(cancelControl);
90
+ }
91
+ const used = new Set(ordered);
92
+ for (const el of elements) {
93
+ if (!used.has(el)) {
94
+ ordered.push(el);
95
+ }
96
+ }
97
+ return ordered;
98
+ };
99
+ /**
100
+ * Option controls in groups use a tabindex pattern where only one
101
+ * option is tabbable (`tabIndex="0"`) while the others are `-1`.
102
+ * This returns the index of that current tabbable option.
103
+ */
104
+ const getTabbableOptionControlIndex = (elements, overlay) => {
105
+ return elements.findIndex((el) => {
106
+ return overlay.contains(el) && isSelectModalOptionControl(el) && el.tabIndex >= 0;
107
+ });
108
+ };
109
+ /**
110
+ * Determines if the overlay's backdrop is always blocking
111
+ * (no background interaction).
19
112
  * Returns false if showBackdrop=false or backdropBreakpoint > 0.
20
113
  */
21
114
  const isBackdropAlwaysBlocking = (el) => {
@@ -136,7 +229,7 @@ const focusElementInOverlay = (hostToFocus, overlay) => {
136
229
  * Should NOT include: Toast
137
230
  */
138
231
  const trapKeyboardFocus = (ev, doc) => {
139
- const lastOverlay = getPresentedOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover');
232
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
140
233
  const target = ev.target;
141
234
  /**
142
235
  * If no active overlay, ignore this event.
@@ -319,6 +412,30 @@ const connectListeners = (doc) => {
319
412
  doc.addEventListener('focus', (ev) => {
320
413
  trapKeyboardFocus(ev, doc);
321
414
  }, true);
415
+ /**
416
+ * Remember which option control last received focus
417
+ * (arrows, click, or Tab). This pattern keeps `tabIndex=0` on the
418
+ * checked/first radio, so the Tab trap uses this when wrapping back
419
+ * into the list or focusing the option-group slot.
420
+ */
421
+ doc.addEventListener('focusin', (ev) => {
422
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
423
+ if (!lastOverlay || lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS)) {
424
+ return;
425
+ }
426
+ const isSheetModal = lastOverlay.classList.contains('modal-sheet');
427
+ if (!isSheetModal) {
428
+ return;
429
+ }
430
+ const target = ev.target;
431
+ if (!(target instanceof HTMLElement)) {
432
+ return;
433
+ }
434
+ const optionHost = getOptionControlHost(target);
435
+ if (optionHost && lastOverlay.contains(optionHost)) {
436
+ lastOverlay.trapLastSheetOptionControl = optionHost;
437
+ }
438
+ }, true);
322
439
  // Listen for keydown events to intercept Tab navigation.
323
440
  // This is needed for Safari and Firefox which may skip focusable
324
441
  // elements or allow focus to escape the overlay.
@@ -328,10 +445,10 @@ const connectListeners = (doc) => {
328
445
  var _a, _b, _c;
329
446
  if (ev.key !== 'Tab' && ev.key !== 'Alt+Tab')
330
447
  return;
331
- const lastOverlay = getPresentedOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover');
448
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
332
449
  if (!lastOverlay || lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS))
333
450
  return;
334
- const activeElement = doc.activeElement;
451
+ const activeElement = getActiveElement(doc);
335
452
  if (activeElement === lastOverlay) {
336
453
  ev.preventDefault();
337
454
  focusFirstDescendant(lastOverlay);
@@ -347,16 +464,32 @@ const connectListeners = (doc) => {
347
464
  if (!isInsideOverlay)
348
465
  return;
349
466
  // Get all focusable elements from both light and shadow DOM
350
- const allFocusable = [
467
+ let allFocusable = [
351
468
  ...lastOverlay.querySelectorAll(focusableQueryString),
352
469
  ...(((_c = lastOverlay.shadowRoot) === null || _c === void 0 ? void 0 : _c.querySelectorAll(focusableQueryString)) || []),
353
470
  ];
471
+ const selectModalEl = lastOverlay.querySelector(ION_SELECT_MODAL_SELECTOR);
472
+ const isSheetModal = lastOverlay.classList.contains('modal-sheet');
473
+ /**
474
+ * Some sheet modal content, including ion-select-modal,
475
+ * renders option containers as `ion-item.select-interface-option`.
476
+ * These can match focusable selectors in some builds but
477
+ * are not intended tab stops. Keep only true interactive
478
+ * controls so Tab can move from options to header
479
+ * controls/handle.
480
+ */
481
+ if (selectModalEl) {
482
+ allFocusable = allFocusable.filter((el) => !el.matches('ion-item.select-interface-option'));
483
+ }
484
+ if (isSheetModal) {
485
+ allFocusable = sortSheetModalFocusables(lastOverlay, allFocusable);
486
+ }
354
487
  if (allFocusable.length === 0) {
355
488
  ev.preventDefault();
356
489
  return;
357
490
  }
358
491
  // Find current element's index (accounting for shadow DOM)
359
- const currentIndex = activeElement
492
+ let currentIndex = activeElement
360
493
  ? allFocusable.findIndex((el) => {
361
494
  var _a;
362
495
  if (el === activeElement)
@@ -367,6 +500,31 @@ const connectListeners = (doc) => {
367
500
  return rootNode instanceof ShadowRoot && rootNode.host === el;
368
501
  })
369
502
  : -1;
503
+ /**
504
+ * Radio/checkbox groups can move focus onto an option with
505
+ * `tabIndex=-1`, while another option still has `tabIndex=0`
506
+ * in the list. `findIndex` then yields -1 and Tab incorrectly
507
+ * wraps to `allFocusable[0]`.
508
+ * Treat focus on any option control inside the sheet modal
509
+ * as the same trap slot as the listed tabbable option
510
+ * (`tabIndex >= 0`) so Tab goes handle → Cancel, not back
511
+ * to the first radio.
512
+ */
513
+ if (currentIndex < 0 && isSheetModal && activeElement) {
514
+ const optionHost = getOptionControlHost(activeElement);
515
+ if (optionHost && lastOverlay.contains(optionHost)) {
516
+ const directIndex = allFocusable.indexOf(optionHost);
517
+ if (directIndex >= 0) {
518
+ currentIndex = directIndex;
519
+ }
520
+ else {
521
+ const tabbableOptionIndex = getTabbableOptionControlIndex(allFocusable, lastOverlay);
522
+ if (tabbableOptionIndex >= 0) {
523
+ currentIndex = tabbableOptionIndex;
524
+ }
525
+ }
526
+ }
527
+ }
370
528
  ev.preventDefault();
371
529
  // Helper to focus an element, handling shadow DOM properly
372
530
  const focusElement = (element) => {
@@ -380,24 +538,43 @@ const connectListeners = (doc) => {
380
538
  }
381
539
  focusVisibleElement(element);
382
540
  };
541
+ let nextIndex;
383
542
  if (ev.shiftKey) {
384
543
  // Shift+Tab: previous element, wrap to last if at first
385
544
  if (currentIndex <= 0) {
386
- focusLastDescendant(lastOverlay);
545
+ nextIndex = allFocusable.length - 1;
387
546
  }
388
547
  else {
389
- focusElement(allFocusable[currentIndex - 1]);
548
+ nextIndex = currentIndex - 1;
390
549
  }
391
550
  }
392
551
  else {
393
552
  // Tab: next element, wrap to first if at last
394
553
  if (currentIndex < 0 || currentIndex >= allFocusable.length - 1) {
395
- focusFirstDescendant(lastOverlay);
554
+ nextIndex = 0;
396
555
  }
397
556
  else {
398
- focusElement(allFocusable[currentIndex + 1]);
557
+ nextIndex = currentIndex + 1;
558
+ }
559
+ }
560
+ const nextEl = allFocusable[nextIndex];
561
+ const overlayTrap = lastOverlay;
562
+ const tabbableOptionIndex = isSheetModal ? getTabbableOptionControlIndex(allFocusable, lastOverlay) : -1;
563
+ /**
564
+ * The trap list only includes one tabbable option host
565
+ * (`tabIndex >= 0`), usually the checked/first radio.
566
+ * `focusin` tracks the real last-focused option; use it
567
+ * whenever Tab would focus that slot (wrap from Cancel,
568
+ * Shift+Tab from handle, etc.).
569
+ */
570
+ let focusTarget = nextEl;
571
+ if (isSheetModal && tabbableOptionIndex >= 0 && nextIndex === tabbableOptionIndex) {
572
+ const saved = overlayTrap.trapLastSheetOptionControl;
573
+ if ((saved === null || saved === void 0 ? void 0 : saved.isConnected) && lastOverlay.contains(saved) && saved !== nextEl) {
574
+ focusTarget = saved;
399
575
  }
400
576
  }
577
+ focusElement(focusTarget);
401
578
  }, true);
402
579
  // handle back-button click
403
580
  doc.addEventListener('ionBackButton', (ev) => {
package/dist/docs.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "timestamp": "2026-05-21T17:43:39",
2
+ "timestamp": "2026-05-22T16:26:35",
3
3
  "compiler": {
4
4
  "name": "@stencil/core",
5
5
  "version": "4.43.0",
package/dist/esm/index.js CHANGED
@@ -14,7 +14,7 @@ export { I as IonicSafeString } from './index-D4ugF_sT.js';
14
14
  export { g as getMode, s as setupConfig } from './config-BwKpO3Is.js';
15
15
  export { o as openURL } from './theme-DaJxRxSQ.js';
16
16
  export { m as menuController } from './index-hpH08p5s.js';
17
- export { b as actionSheetController, a as alertController, l as loadingController, m as modalController, p as pickerController, c as popoverController, t as toastController } from './overlays-rwDDzEs4.js';
17
+ export { b as actionSheetController, a as alertController, l as loadingController, m as modalController, p as pickerController, c as popoverController, t as toastController } from './overlays-ttYCMKRp.js';
18
18
  import './gesture-controller-BTEOs1at.js';
19
19
  import './focus-visible-vXpMhGrs.js';
20
20
  import './framework-delegate-CjVwn_KZ.js';
@@ -5,7 +5,7 @@ import { r as registerInstance, c as createEvent, a as readTask, h, d as Host, g
5
5
  import { c as createButtonActiveGesture } from './button-active-g6ZnZzDZ.js';
6
6
  import { r as raf } from './helpers-Do7zwvM1.js';
7
7
  import { c as createLockController } from './lock-controller-B-hirT0v.js';
8
- import { d as createDelegateController, e as createTriggerController, B as BACKDROP, i as isCancel, f as present, g as dismiss, h as eventMethod, s as safeCall, j as prepareOverlay, k as setOverlayId } from './overlays-rwDDzEs4.js';
8
+ import { d as createDelegateController, e as createTriggerController, B as BACKDROP, i as isCancel, f as present, g as dismiss, h as eventMethod, s as safeCall, j as prepareOverlay, k as setOverlayId } from './overlays-ttYCMKRp.js';
9
9
  import { r as renderOptionLabel } from './select-option-render-B2qc5ZP7.js';
10
10
  import { g as getClassMap } from './theme-DaJxRxSQ.js';
11
11
  import { b as getIonMode, c as getIonTheme } from './ionic-global-CAZb-5i-.js';
@@ -6,7 +6,7 @@ import { E as ENABLE_HTML_CONTENT_DEFAULT } from './config-BwKpO3Is.js';
6
6
  import { c as createButtonActiveGesture } from './button-active-g6ZnZzDZ.js';
7
7
  import { r as raf } from './helpers-Do7zwvM1.js';
8
8
  import { c as createLockController } from './lock-controller-B-hirT0v.js';
9
- import { d as createDelegateController, e as createTriggerController, B as BACKDROP, i as isCancel, j as prepareOverlay, k as setOverlayId, f as present, g as dismiss, h as eventMethod, s as safeCall } from './overlays-rwDDzEs4.js';
9
+ import { d as createDelegateController, e as createTriggerController, B as BACKDROP, i as isCancel, j as prepareOverlay, k as setOverlayId, f as present, g as dismiss, h as eventMethod, s as safeCall } from './overlays-ttYCMKRp.js';
10
10
  import { s as sanitizeDOMString } from './index-D4ugF_sT.js';
11
11
  import { r as renderOptionLabel } from './select-option-render-B2qc5ZP7.js';
12
12
  import { g as getClassMap } from './theme-DaJxRxSQ.js';
@@ -6,7 +6,7 @@ import { c as caretLeftSvg } from './caret-left-fIOYmaqA.js';
6
6
  import { c as caretRightSvg } from './caret-right-BYSs-jZz.js';
7
7
  import { startFocusVisible } from './focus-visible-vXpMhGrs.js';
8
8
  import { r as raf, g as getElementRoot, a as renderHiddenInput, e as clamp } from './helpers-Do7zwvM1.js';
9
- import { F as FOCUS_TRAP_DISABLE_CLASS, d as createDelegateController, e as createTriggerController, B as BACKDROP, i as isCancel, j as prepareOverlay, k as setOverlayId, f as present, g as dismiss, h as eventMethod, s as safeCall } from './overlays-rwDDzEs4.js';
9
+ import { F as FOCUS_TRAP_DISABLE_CLASS, d as createDelegateController, e as createTriggerController, B as BACKDROP, i as isCancel, j as prepareOverlay, k as setOverlayId, f as present, g as dismiss, h as eventMethod, s as safeCall } from './overlays-ttYCMKRp.js';
10
10
  import { i as isRTL } from './dir-C53feagD.js';
11
11
  import { c as createColorClasses, g as getClassMap } from './theme-DaJxRxSQ.js';
12
12
  import { o as chevronForward, c as chevronBack, p as caretDownSharp, q as caretUpSharp, l as chevronDown } from './index-D2tu5BUg.js';