Skip to content

Fix sam-datepicker-v2 outside-click close and the tabindex/aria-hidden restore leak #666

Description

@fpigeonjr

Two entangled defects in experimental/date-range-v2/datepicker/picker.component.ts. Neither can be fixed alone — fixing the first alone makes the picker unopenable by mouse — so they are tracked together.

What to build

Defect 1 — calendarpopup never resolves, so outside-click-to-close is dead code

calendarpopup is declared @ViewChild("calendarpopup", { static: true }) but the element it targets is inside an *ngIf="showCalendar" block. A static query resolves once, before the first change detection run, while showCalendar is still false — so calendarpopup is undefined and, being static, is never updated.

handleGlobalClick guards on this.calendarpopup, so its entire body is unreachable:

  • Clicking outside the calendar does not close it.
  • enablePageTabIndex() never runs on that path. Since disablePageTabIndex() sets tabindex="-1" and aria-hidden="true" on every focusable element on the page when the calendar opens, that state is never restored. After opening the calendar and clicking away, the rest of the page is left unfocusable and hidden from screen readers — an accessibility trap.

Defect 2 — strict target comparison treats the icon's own child as an outside click

handleGlobalClick compares:

this.calendarButton.nativeElement !== event.target

The calendar button contains a screen-reader child span:

<span class="fa fa-calendar" #calendarButton (click)="onInputClick()">
  <span class="sr-only">Calendar</span>
</span>

A real mouse click at the icon's centre lands on the inner .sr-only span, not on #calendarButton, so strict inequality treats the button's own child as an outside click. This is latent today (masked by Defect 1), but as soon as calendarpopup resolves, the opening click immediately closes the calendar again in the same event — verified with a MutationObserver showing the popup added and removed ~1.8ms apart. The picker becomes impossible to open by mouse (keyboard Enter still works, since it dispatches no click).

The comparison should test containment, e.g. !this.calendarButton.nativeElement.contains(event.target).

Background

Found while manually testing #660, which changed the @ViewChild to non-static. That change was reverted from #660 because on its own it introduced Defect 2's user-visible symptom. Two specs in picker.spec.ts are it.skipped with pointers to this issue:

  • should close the calendar when clicking outside of it — cannot pass while the ViewChild is static.
  • should not close the calendar when clicking the calendar button — passes only vacuously (the guard early-returns because calendarpopup is undefined), so it asserts nothing.

Note that a unit spec calling handleGlobalClick directly with a synthetic { target } object passes under both the broken and fixed comparison, and jsdom does not reproduce the hit-testing behaviour that makes a real click land on the child span. Verification must use a real browser click via Playwright.

Acceptance criteria

  • calendarpopup resolves when the calendar is open (non-static query, or another approach that makes handleGlobalClick reachable)
  • handleGlobalClick uses containment rather than strict identity when comparing event.target against the calendar button
  • Playwright e2e spec opens sam-datepicker-v2 with a real mouse click on the calendar icon and asserts the calendar is open and stays open
  • Playwright e2e spec asserts clicking outside the calendar closes it
  • Playwright e2e spec asserts tabindex / aria-hidden on previously-focusable page elements are restored after the calendar closes via outside click
  • Opening via keyboard (Enter on the calendar icon) still works
  • The two it.skipped specs in picker.spec.ts are un-skipped or replaced by equivalent coverage, with no vacuously-passing assertions left behind
  • Day selection, Cancel, and month/year navigation still work

Blocked by

Note this component also pulls in moment (a CommonJS dependency that produces a build warning) and Angular forms plumbing, which the tabs harness in #665 does not exercise — expect a small amount of additional harness setup beyond adding the route.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions