Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/cdk/a11y/focus-trap/focus-trap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,24 @@ describe('FocusTrap', () => {
expect(() => focusTrapInstance.focusFirstTabbableElement()).not.toThrow();
expect(() => focusTrapInstance.focusLastTabbableElement()).not.toThrow();
});

it('should find tabbable elements in shadow DOM', () => {
if (!_supportsShadowDom()) {
return;
}

const fixture = TestBed.createComponent(FocusTrapWithShadowDom);
fixture.detectChanges();
const focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;

// The shadow button should be found as the first tabbable element
expect(focusTrapInstance.focusFirstTabbableElement()).toBe(true);
expect(getActiveElement().textContent?.trim()).toBe('Shadow Button');

// The shadow button should also be found as the last tabbable element
expect(focusTrapInstance.focusLastTabbableElement()).toBe(true);
expect(getActiveElement().textContent?.trim()).toBe('Shadow Button');
});
});

describe('with autoCapture', () => {
Expand Down Expand Up @@ -448,3 +466,25 @@ class FocusTrapInsidePortal {
@ViewChild('template') template: TemplateRef<any>;
@ViewChild(CdkPortalOutlet) portalOutlet: CdkPortalOutlet;
}

@Component({
template: `
<div cdkTrapFocus>
<div #shadowHost></div>
</div>
`,
imports: [A11yModule],
})
class FocusTrapWithShadowDom {
@ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus;
@ViewChild('shadowHost', {static: true}) shadowHost: any;

ngAfterViewInit() {
if (_supportsShadowDom()) {
const shadowRoot = this.shadowHost.nativeElement.attachShadow({mode: 'open'});
const shadowButton = document.createElement('button');
shadowButton.textContent = 'Shadow Button';
shadowRoot.appendChild(shadowButton);
}
}
}
33 changes: 32 additions & 1 deletion src/cdk/a11y/focus-trap/focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,22 @@ export class FocusTrap {
return root;
}

// Check shadow DOM first if it exists
if (root.shadowRoot) {
const shadowChildren = root.shadowRoot.children;
for (let i = 0; i < shadowChildren.length; i++) {
const tabbableChild =
shadowChildren[i].nodeType === this._document.ELEMENT_NODE
? this._getFirstTabbableElement(shadowChildren[i] as HTMLElement)
: null;

if (tabbableChild) {
return tabbableChild;
}
}
}

// Then check light DOM children
const children = root.children;

for (let i = 0; i < children.length; i++) {
Expand All @@ -308,7 +324,7 @@ export class FocusTrap {
return root;
}

// Iterate in reverse DOM order.
// Iterate in reverse DOM order - check light DOM children first
const children = root.children;

for (let i = children.length - 1; i >= 0; i--) {
Expand All @@ -322,6 +338,21 @@ export class FocusTrap {
}
}

// Then check shadow DOM if it exists
if (root.shadowRoot) {
const shadowChildren = root.shadowRoot.children;
for (let i = shadowChildren.length - 1; i >= 0; i--) {
const tabbableChild =
shadowChildren[i].nodeType === this._document.ELEMENT_NODE
? this._getLastTabbableElement(shadowChildren[i] as HTMLElement)
: null;

if (tabbableChild) {
return tabbableChild;
}
}
}

return null;
}

Expand Down
2 changes: 1 addition & 1 deletion src/material/datepicker/calendar-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
}

<!-- Create the first row separately so we can include a special spacer cell. -->
@for (row of rows; track _trackRow(row); let rowIndex = $index) {
@for (row of rows; track _trackRow($index, row); let rowIndex = $index) {
<tr role="row">
<!--
This cell is purely decorative, but we can't put `aria-hidden` or `role="presentation"` on it,
Expand Down
6 changes: 5 additions & 1 deletion src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,11 @@ export class MatCalendarBody<D = any> implements OnChanges, OnDestroy, AfterView
* key on the row, but that would require a breaking change for the `rows` input. We don't
* use the built-in identity tracking, because it logs warnings.
*/
_trackRow = (row: MatCalendarCell[]) => row;
_trackRow = (index: number, row: MatCalendarCell[]) => {
// Use a combination of index and first cell value for more stable tracking
// This prevents LView detachment errors when navigating between months
return row.length > 0 ? `${index}-${row[0].value}` : index;
};

constructor(...args: unknown[]);

Expand Down
23 changes: 23 additions & 0 deletions src/material/datepicker/month-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,29 @@ describe('MatMonthView', () => {
expect(calendarInstance.date).toEqual(new Date(2017, JAN, 19));
});

it('should not crash when navigating to next month with down arrow in range mode', () => {
// Set up a range selection to reproduce the bug scenario
testComponent.selected = new DateRange(new Date(2017, JAN, 15), null);
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

// Navigate to a date near the end of the month
calendarInstance.date = new Date(2017, JAN, 28);
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

// This should trigger month navigation and test the fix for the LView detachment error
expect(() => {
for (let i = 0; i < 10; i++) {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW);
fixture.detectChanges();
}
}).not.toThrow();

// Verify we navigated to the next month
expect(calendarInstance.date.getMonth()).toBe(FEB);
});

it('should go to beginning of the month on home press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME);
fixture.detectChanges();
Expand Down
Loading