Fix sam-datepicker-v2 outside-click close and tabindex/aria-hidden restore leak - #674
Conversation
…store leak - calendarpopup ViewChild changed from static:true to the (non-static) default so it resolves once the *ngIf="showCalendar" element renders, making the outside-click-close path in handleGlobalClick reachable. - handleGlobalClick now compares containment (calendarButton.nativeElement.contains(event.target)) instead of strict identity, so a real click landing on the icon's .sr-only child span is not treated as an outside click. - Un-skip the two picker.spec.ts specs that were blocked on this fix; replaced their vacuous-pass commentary with real assertions. - Add a /datepicker gallery route in test-app (following the #665 tabs-gallery convention) and test-app/e2e/datepicker.spec.ts covering: real-mouse-click open+stays-open, outside-click close, tabindex/aria-hidden restore on sibling elements, keyboard Enter open, and day-select/cancel/month-year-nav regression coverage. Fixes #666
There was a problem hiding this comment.
🟡 Changes recommended
The new Playwright spec introduces a fixed waitForTimeout(250) that can slow the suite and add flakiness; prefer a deterministic next-frame wait (see comment).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes sam-datepicker-v2’s outside-click-to-close behavior and ensures tabindex/aria-hidden state is properly restored after the calendar closes, including coverage for the real-browser “click lands on icon child” scenario via Playwright.
Changes:
- Make
calendarpopupa non-static@ViewChildand update outside-click logic to use containment for the calendar button. - Un-skip and strengthen unit specs in
picker.spec.tsto cover the now-reachable close behavior and the button containment branch. - Add a
/datepickergallery route plus Playwright e2e coverage validating open/close and focusability/screen-reader restoration.
File summaries
| File | Description |
|---|---|
src/ui-kit/experimental/date-range-v2/datepicker/picker.component.ts |
Fix @ViewChild resolution and outside-click containment logic; ensures tab/ARIA restoration runs when closing. |
src/ui-kit/experimental/date-range-v2/datepicker/picker.spec.ts |
Un-skip and update unit tests to assert outside-click close and “click on button doesn’t close”. |
test-app/src/app/app.module.ts |
Register /datepicker route and import the standalone datepicker gallery component. |
test-app/src/app/datepicker-gallery/datepicker-gallery.component.ts |
Add standalone gallery component wiring FormsModule + SamDateRangeV2Module, providing SamFormService. |
test-app/src/app/datepicker-gallery/datepicker-gallery.component.html |
Gallery page with sibling focusable elements to validate tabindex/aria-hidden disabling/restoration. |
test-app/src/app/datepicker-gallery/datepicker-gallery.component.spec.ts |
Basic creation test for the new gallery component. |
test-app/e2e/datepicker.spec.ts |
Playwright tests for real-click open/close behavior and tabindex/aria-hidden restore behavior. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…wait Copilot review flagged the fixed waitForTimeout(250) in datepicker.spec.ts as a suite-slowdown/flakiness risk. handleGlobalClick runs synchronously during the click's document-level bubble phase, so waiting two animation frames is sufficient to let it run before re-asserting popup visibility.
The "opens and stays open" spec clicked `.datepicker .fa-calendar` at its
centre and relied on the browser's hit-test landing on the icon's `.sr-only`
child span -- which is what makes `event.target` a *descendant* of
`#calendarButton` and therefore what distinguishes the fixed
`contains(event.target)` check from the old strict `!==` comparison.
That was verified not to hold: with the fix reverted to `!==`, the previous
centre-click spec still passed, so it was not actually guarding Defect 2. The
hit-test outcome also depends on CSS this harness does not load (`test-app`
has no FontAwesome webfont CSS and no `.sr-only` rule, so the unclipped child
fills the icon's box), meaning any future harness styling would change which
element is hit without any test turning red.
Click the `.sr-only` child explicitly with `{ force: true }` instead, so the
assertion is pinned to the defect regardless of what CSS is present.
Re-verified by reverting the component fix: the spec now fails on
`expect(popup).toBeVisible()` as intended, and passes with the fix in place.
Manual testing note:
|
| spec | with !== reintroduced |
|---|---|
centre click on .fa-calendar (before) |
✅ passed — not guarding the defect |
explicit click on .fa-calendar .sr-only (now) |
❌ failed on expect(popup).toBeVisible() |
Defect 2 only reproduces when event.target is a descendant of #calendarButton, and which element a real click lands on depends on CSS the harness doesn't load. Clicking the child explicitly with { force: true } pins the assertion to the defect regardless of stylesheets — and means #681's harness styling can't silently defang it (called out in that issue's AC).
Verification
Playwright 7 passed (datepicker x5, tabs, smoke)
Vitest 152 files / 1383 tests passed
coverage ✓ statements 88.79 · branches 78.83 · functions 85.95 · lines 88.78 (all above floor)
format:check ✓ clean
coverage-floor.json and eslint-baseline.json untouched, per the ratchet rule in AGENTS.md.
…store leak (#674) * Fix sam-datepicker-v2 outside-click close and tabindex/aria-hidden restore leak - calendarpopup ViewChild changed from static:true to the (non-static) default so it resolves once the *ngIf="showCalendar" element renders, making the outside-click-close path in handleGlobalClick reachable. - handleGlobalClick now compares containment (calendarButton.nativeElement.contains(event.target)) instead of strict identity, so a real click landing on the icon's .sr-only child span is not treated as an outside click. - Un-skip the two picker.spec.ts specs that were blocked on this fix; replaced their vacuous-pass commentary with real assertions. - Add a /datepicker gallery route in test-app (following the #665 tabs-gallery convention) and test-app/e2e/datepicker.spec.ts covering: real-mouse-click open+stays-open, outside-click close, tabindex/aria-hidden restore on sibling elements, keyboard Enter open, and day-select/cancel/month-year-nav regression coverage. Fixes #666 * Address PR review feedback: replace fixed timeout with deterministic wait Copilot review flagged the fixed waitForTimeout(250) in datepicker.spec.ts as a suite-slowdown/flakiness risk. handleGlobalClick runs synchronously during the click's document-level bubble phase, so waiting two animation frames is sufficient to let it run before re-asserting popup visibility. * Target the .sr-only child in the datepicker open-click e2e guard The "opens and stays open" spec clicked `.datepicker .fa-calendar` at its centre and relied on the browser's hit-test landing on the icon's `.sr-only` child span -- which is what makes `event.target` a *descendant* of `#calendarButton` and therefore what distinguishes the fixed `contains(event.target)` check from the old strict `!==` comparison. That was verified not to hold: with the fix reverted to `!==`, the previous centre-click spec still passed, so it was not actually guarding Defect 2. The hit-test outcome also depends on CSS this harness does not load (`test-app` has no FontAwesome webfont CSS and no `.sr-only` rule, so the unclipped child fills the icon's box), meaning any future harness styling would change which element is hit without any test turning red. Click the `.sr-only` child explicitly with `{ force: true }` instead, so the assertion is pinned to the defect regardless of what CSS is present. Re-verified by reverting the component fix: the spec now fails on `expect(popup).toBeVisible()` as intended, and passes with the fix in place.
- Rebase branch tip onto latest #675 (gh-582) so this PR's diff/baseline reflect only #586's type-safety changes, not #675's still-open autofix set. #675 is CLEAN/MERGEABLE and independently reviewed; stacking is the documented convention for this repo's slice-based PRs. - Retarget PR base to gh-582-apply-safe-eslint-autofixes-and-lower-the-baseline so the GitHub diff matches (rebase alone doesn't move a PR's base). - Drop the #674 datepicker outside-click fix and #677 sidenav RxJS fix commits from this branch entirely (dropped during rebase) -- both are unrelated bugfixes already merged to master via their own PRs; they were only present here as inherited ancestry from an earlier base choice, not something this PR should carry or take credit for. - pagination.component.ts: options.value widened to 'string | number' -- the template already supports numeric option values via attribute binding; the prior 'any[]' allowed this and the fix's 'string' literal would have narrowed a supported case. - date-time-display.pipe.ts: transform() parameter widened to include 'undefined' explicitly -- the implementation's own guard branch handles undefined and the pipe previously accepted it; the stricter signature would have been a source-compatibility break for existing callers. - types.ts: HistoryNodeType.queryParams value type widened to include readonly arrays and null, matching Angular Router's actual accepted queryParams value shapes (repeated params, param removal) instead of the narrower 'string | number | boolean' which would reject valid existing usage.
Description
experimental/date-range-v2/datepicker/picker.component.tshad two entangleddefects that had to be fixed together (fixing either alone regresses the
other):
calendarpopupnever resolves, so outside-click-to-close is dead code.@ViewChild("calendarpopup", { static: true })targeted an element insidean
*ngIf="showCalendar"block. A static query resolves once, before thefirst change-detection run, while
showCalendaris stillfalse— socalendarpopupstayedundefinedforever.handleGlobalClickguards onthis.calendarpopup, so its whole body — includingenablePageTabIndex(), which restores thetabindex/aria-hiddenattributes
disablePageTabIndex()strips from every focusable element onthe page when the calendar opens — was unreachable. Opening the calendar
and clicking away left the rest of the page permanently unfocusable and
hidden from screen readers.
Fix: changed the
@ViewChildto non-static (the default), so it resolvesonce the
*ngIf-gated element actually renders.Strict target comparison treats the icon's own child as an outside
click.
handleGlobalClickcomparedthis.calendarButton.nativeElement !== event.target. The calendarbutton contains a
.sr-onlychild span; a real mouse click at theicon's centre lands on that child, not on
#calendarButtonitself, sostrict inequality treated the button's own child as an outside click.
This was latent while defect 1 masked it, but once
calendarpopupresolves, the opening click immediately closes the calendar again in
the same event — the picker becomes unopenable by mouse (keyboard Enter
still worked, since it dispatches no click).
Fix: changed the comparison to containment —
!this.calendarButton.nativeElement.contains(event.target).Also un-skipped the two
picker.spec.tsspecs that were blocked on this fix(
should close the calendar when clicking outside of it,should not close the calendar when clicking the calendar button) andreplaced their stale "why this is skipped" comments with real assertions —
the second spec previously passed only vacuously, because the guard on
this.calendarpopupalways early-returned.Since jsdom has no layout engine to hit-test against, a synthetic
{ target }object can't reproduce defect 2 (a click landing on the icon'schild span) — only a real browser click can. Added a
/datepickergalleryroute to
test-app(following the/tabsconvention from #665) and a newtest-app/e2e/datepicker.spec.tsPlaywright spec that exercises the fixwith a real mouse click.
Motivation and Context
Closes #666
Type of Change (Select One and Apply Label)
bugfixlabelenhancementlabelbreakinglabelmaintenancelabelHow to Test
npm ci && npm ci --prefix test-appnpm --prefix test-app test— confirms unit specs pass, including thetwo newly-un-skipped
picker.spec.tsspecsnpm run coverage:check— confirms coverage stays at/above the floornpx playwright install --with-deps chromium && npm --prefix test-app run test:e2e—runs
test-app/e2e/datepicker.spec.tsagainst a real Chromium browsercd test-app && npm start, openhttp://localhost:4200/datepickerelements are disabled while it's open and restored once it closes
Expected result: The calendar opens on a real mouse click and stays
open; clicking outside closes it and restores page focusability/screen-reader
visibility; all existing interactions (day select, cancel, month/year nav,
keyboard open) are unaffected.
Screenshots (if appropriate)
N/A — no visual changes; behavior-only fix in a component with no styling
changes.
Checklist
gh-<number>-<slug>)format:checkpasses (npm run format:check)lintpasses (npm run lint)buildpasses (cd test-app && npm run build)cd test-app && npm test)