Skip to content

Fix inactive tab content rendering on top of active tab in sam-tabs-next - #668

Merged
fpigeonjr merged 4 commits into
masterfrom
gh-665-fix-inactive-tab-overlap
Sep 1, 2026
Merged

Fix inactive tab content rendering on top of active tab in sam-tabs-next#668
fpigeonjr merged 4 commits into
masterfrom
gh-665-fix-inactive-tab-overlap

Conversation

@fpigeonjr

Copy link
Copy Markdown
Contributor

Description

Two parts, per the issue:

1. Shared Playwright component-render harness in test-app. test-app as committed on master cannot render any library component (no paths mapping, no routing, BrowserModule only, a single placeholder smoke spec). Added:

  • tsconfig.json / tsconfig.app.json: paths mapping @gsa-sam/sam-ui-elements → root src/, plus skipLibCheck
  • A routed shell in app.module.ts: / → new HomeComponent (holds the original CLI placeholder content, so smoke.spec.ts still has something to assert against), /tabs → new TabsGalleryComponent rendering <sam-tabs-next> with two tabs of real content
  • BrowserAnimationsModule (not NoopAnimationsModule), since tab-body.ts has a real translateTab animation trigger that a browser test needs to exercise for real
  • SamTabsNextModule imported via the deep path @gsa-sam/sam-ui-elements/src/ui-kit/experimental/tabs (the package root barrel doesn't export it)
  • test-app/angular.json swapped build/serve to @angular-builders/custom-esbuild (still esbuild/Vite under the hood — not a webpack regression) so a custom resolution plugin could be registered
  • New test-app/esbuild/dedupe-angular-plugin.ts: forces @angular/*, rxjs, and zone.js to resolve from a single copy (test-app/node_modules) no matter which physical source tree (root src/ui-kit/... vs test-app/src/app/...) does the importing. Without this, the two trees resolve two separate copies of @angular/core into one bundle, which splits Angular's DI-context tracking and throws NG0203 the instant any root-tree component is instantiated. This mirrors the resolve.dedupe workaround vitest.config.mts already applies for the Vitest/unit-test path — same problem, now also covered on the app-serve/build path.

2. The tab overlap bug itself. tab-group.scss's .mat-tab-body { display: block; } is an author-stylesheet rule, which always wins the cascade over the browser's built-in [hidden] { display: none; } user-agent rule that tab-group.html relies on ([hidden]="selectedIndex !== i") to hide inactive tab bodies — so the inactive tab's absolutely-positioned content never actually gets hidden and renders stacked on top of the active tab. Fixed with an explicit &[hidden] { display: none; } rule so [hidden] wins.

Motivation and Context

Closes #665

Type of Change (Select One and Apply Label)

  • Bug fix (non-breaking change which fixes an issue) → Apply bugfix label
  • New feature (non-breaking change which adds functionality) → Apply enhancement label
  • Breaking change (fix or feature that would cause existing functionality to change) → Apply breaking label
  • Documentation / configuration update → Apply maintenance label

How to Test

  1. npm ci && npm ci --prefix test-app
  2. npm --prefix test-app run test:e2e — runs smoke.spec.ts and the new tabs.spec.ts; both should pass
  3. To see the bug reproduce: git stash -- src/ui-kit/experimental/tabs/tab-group.scss then re-run npm --prefix test-app run test:e2etabs.spec.ts fails (previously-active tab content still visible immediately after switching tabs); git stash pop to restore the fix
  4. npm --prefix test-app test — full unit suite (1377 tests), including existing tab-group.spec.ts and tab-header.spec.ts (ink-bar, keyboard nav), all pass
  5. Manual check: cd test-app && npm start, open http://localhost:4200/tabs, click between "Tab One" / "Tab Two" — only the active tab's content should be visible, with the slide animation still playing

Expected result: test:e2e, unit tests, lint baseline, coverage floor, and format checks all pass; only the active tab's content is ever visible in the browser, with no regression to the ink-bar position, keyboard navigation, or the centering/slide animation.

Screenshots (if appropriate)

N/A — behavioral/CSS fix, best verified via the new e2e spec and the manual repro steps above rather than a static screenshot.

Checklist

  • Branch name follows convention (e.g. gh-<number>-<slug>)
  • PR title starts with a verb in the imperative mood
  • I have self-reviewed my own code
  • format:check passes (npm run format:check)
  • lint passes (npm run lint)
  • build passes (cd test-app && npm run build)
  • Tests pass and coverage is reported (cd test-app && npm test)
  • If this change requires a documentation update, I have updated it accordingly
  • If there are dependent changes, they have been merged and published in downstream modules

Add a shared Playwright component-render harness in test-app (routed
gallery shell with a /tabs route, BrowserAnimationsModule, deep-imported
SamTabsNextModule) that can actually render library components from the
raw source tree, then use it to reproduce and fix the tab-overlap bug.

- test-app/tsconfig.json + tsconfig.app.json: paths mapping resolving
  @gsa-sam/sam-ui-elements to the root src/ tree, plus skipLibCheck
- app.module.ts: routed shell (/ -> HomeComponent, /tabs ->
  TabsGalleryComponent), BrowserAnimationsModule (not Noop), imports
  SamTabsNextModule
- app.component: reduced to a lean <router-outlet> shell; original
  placeholder content moved to new HomeComponent so smoke.spec.ts still
  has a home page to assert against
- New TabsGalleryComponent renders <sam-tabs-next> with two tabs of real
  content, at the /tabs route
- test-app/angular.json: swapped build/serve to
  @angular-builders/custom-esbuild (still esbuild/Vite under the hood,
  no webpack) so a plugin can be registered
- New test-app/esbuild/dedupe-angular-plugin.ts: forces @angular/*,
  rxjs, and zone.js to resolve from test-app/node_modules regardless of
  which physical source tree does the importing. Without this, files
  under root src/ui-kit and files under test-app/src/app resolve two
  separate copies of @angular/core into the same bundle, which breaks
  Angular's DI context tracking and throws NG0203 as soon as any
  root-tree component is instantiated. Mirrors the resolve.dedupe
  workaround vitest.config.mts already applies for the Vitest path.
- New test-app/e2e/tabs.spec.ts: navigates to /tabs, switches tabs, and
  asserts the previously-active tab's content is hidden immediately
  (tight 100ms window, since the animation eventually detaches content
  regardless of the CSS bug, so a default-timeout assertion would not
  have caught the regression)

The actual bug: tab-group.scss's .mat-tab-body { display: block; } is
an author-stylesheet rule, which always wins the cascade over the
browser's built-in [hidden] { display: none; } user-agent rule that
tab-group.html relies on to hide inactive tab bodies. Fixed by adding
an explicit &[hidden] { display: none; } rule so [hidden] wins.

Confirmed the new e2e spec fails against the unpatched CSS (reproduces
the bug) and passes after the fix. Existing tab-group.spec.ts and
tab-header.spec.ts (ink-bar, keyboard nav) unaffected.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 19 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • test-app/package-lock.json: Generated file

Match the rest of test-app/package.json, which pins every dependency
exactly (no ^/~ ranges).
test-app compiles the library straight from the root src/ tree (see
the paths mapping in test-app/tsconfig.json), so those library source
files' @angular/*, @angular/cdk, rxjs, and zone.js imports need the
root node_modules to be present -- they're peerDependencies of the
root package, not test-app's own dependencies. The e2e workflow only
ran `npm install --prefix test-app`, so on a clean runner (no root
node_modules) esbuild couldn't resolve any of those imports from
library source files, and the Playwright webServer never came up
before the 120s timeout.

Verified locally by removing both node_modules trees and reinstalling
exactly as the workflow now does; npm run test:e2e passes.
@fpigeonjr
fpigeonjr marked this pull request as ready for review September 1, 2026 14:22
@fpigeonjr
fpigeonjr requested review from a team and christyhermansen as code owners September 1, 2026 14:22

@fpigeonjr fpigeonjr left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: ✅ Approve

Result ⛔ blocking ⚠️ important 💡 suggestion
✅ Approve 0 5 3

Note

The CSS fix and the harness are both correct — I verified the red/green transition and that the entering tab's centering animation still interpolates. The findings below are all about the dedupe plugin's resolution strategy and the harness's durability for #666/#583.

Must fix before merge

No blocking findings.

Should verify before merge

🟡 require.resolve forces rxjs to its CommonJS entry, inflating the bundle ~42% and permanently adding "not ESM" build warningstest-app/esbuild/dedupe-angular-plugin.ts:56
require.resolve applies Node's require condition, so rxjs resolves to dist/cjs/index.js rather than the ESM entry esbuild would normally pick — which is exactly why every build now logs two Module 'rxjs' ... is not ESM / optimization bailouts warnings. Measured on this branch: as committed, main.js is 474.58 kB with 2 warnings; swapping the onResolve body to esbuild's own build.resolve() (with a pluginData re-entry guard) gives 334.29 kB, 0 warnings, still a single rxjs copy and a single @angular/core, and e2e still passes. That's the strictly better option — it keeps the dedupe and the ESM entry, unlike simply dropping rxjs from the list (350.63 kB, but two rxjs copies creep back in).

🟡 The dedupe list has already drifted from vitest.config.mts, and the next gallery route may re-trigger NG0203test-app/esbuild/dedupe-angular-plugin.ts:25
The plugin's list is the Vitest resolve.dedupe list minus @fortawesome/angular-fontawesome and @fortawesome/fontawesome-svg-core. Those two are in the Vitest list because library source needs them deduped; the moment a gallery route renders a FontAwesome-using component, the esbuild path hits the same duplicate-instance failure this plugin exists to prevent — and the symptom (NG0203) is famously hard to trace back here. Since the doc comment already names vitest.config.mts as the sibling, export the array from one module and import it in both, so drift becomes impossible.

🟡 --no-package-lock on the new root install makes the e2e job non-reproducible against unbounded peer ranges.github/workflows/e2e.yml:26
The root package declares its Angular stack in peerDependencies with ranges including rxjs: >=7.5.0 and @angular/cdk: ^19.2.15, and --no-package-lock discards the pins in package-lock.json (which currently resolves @angular/core to 19.2.25) and re-resolves them fresh on every run. An upstream rxjs 8 release would silently enter this build; ci.yml avoids that by using npm ci for both workspaces. Worth switching to npm ci here. Note the reason a root install is needed at all is AOT typechecking, not bundling — the CI failure was TS2307 ... [plugin angular-compiler], and the esbuild plugin only rewrites the bundler's module graph. If a @angular/* entry in the paths map can satisfy the compiler instead, the root install step and the version-skew surface both disappear; that seems worth a look before locking this in.

🟡 The regression gate races a 100 ms timer instead of asserting the property under testtest-app/e2e/tabs.spec.ts:26
not.toBeVisible({ timeout: 100 }) is correct today (I confirmed it fails on the unfixed SCSS and passes on the fix), but it couples the gate to how fast the runner processes the click plus change detection — a loaded CI runner that needs >100 ms to flip [hidden] produces a false red. The issue explicitly asked for deterministic assertions ("Assert toBeVisible(), computed style, and bounding boxes — deterministic, and it states intent"), and the deterministic form is right there: assert getComputedStyle on the leaving md-tab-body is none, or that the two bodies' bounding boxes don't overlap. That states the cascade intent directly and removes the timing coupling.

🟡 The harness convention this PR exists to establish isn't written down anywhereAGENTS.md
The issue's framing was that getting the convention right makes #666 "add a /datepicker route plus a spec" — but nothing in AGENTS.md or test-app/README.md records the one-route-per-component URL convention, the paths mapping, or, most importantly, that esbuild/dedupe-angular-plugin.ts exists and why. AGENTS.md's Testing section already documents the Vitest alias ordering and the @gsa-sam/icons alias for exactly this reason; the next person to add a route will otherwise rediscover NG0203 from scratch. The PR's own "documentation update" checkbox is still unchecked.

💡 Suggestions and notes

🔵 zone.js in the dedupe list is dead weight and quietly papers over a version skewtest-app/esbuild/dedupe-angular-plugin.ts:36
No file under src/ imports zone.js (it only enters via the polyfills array, which is already resolved from test-app), so the entry does nothing. It is not harmless, though: root has 0.15.1 and test-app has 0.16.2, so the entry's only real effect is to hide that skew rather than surface it.

🔵 home.component.spec.ts is a verbatim copy of the old AppComponent spec, including "should create the app"test-app/src/app/home/home.component.spec.ts:9
The move is fine, but the test names now describe a component that no longer does this ("the app" is the router shell). Renaming to "should create" / "should render the title in an h1" keeps the spec honest, and the empty home.component.css carried along by the rename could go with it.

🔵 extract-i18n is now on a third builder familytest-app/angular.json:53
build and serve moved to @angular-builders/custom-esbuild, but extract-i18n went to @angular/build. Nothing exercises that target, so it's cosmetic — aligning all three on one family just removes a "why is this one different?" question later.

Verified, no finding:

  • The &[hidden] fix makes the leaving tab display: none synchronously, which means its 500 ms slide-out is no longer rendered. I checked this doesn't leak an attached portal — translateTab.done still fires and _portalHost.detach() still runs (leaving body reports hasContent: false after settle).
  • Entering-tab centering animation still runs post-fix: content transform interpolates matrix(1,0,0,1,17.32,0)matrix(1,0,0,1,0,0) over ~500 ms.
  • npm run validate:publish passes; tab-group.scss is not in scripts/consumer-deep-imports.json, so no consumer deep-import contract is touched.
  • Neither coverage-floor.json nor eslint-baseline.json was edited, per the ratchet rules in AGENTS.md.
  • Spec AC all met: paths + skipLibCheck, single /tabs route, BrowserAnimationsModule (not Noop), smoke.spec.ts still passes, and no styles/scripts added to angular.json as the issue required.
  • Ink-bar position and keyboard nav are still only covered by jsdom unit specs, which can't see real-browser layout — the same limitation that motivated this harness. Now that the harness exists, those are cheap follow-up assertions rather than a gap in this PR.

Recommendation

✅ Approve — the cascade fix is correct and properly gated by a spec I confirmed is red before it and green after, and every acceptance criterion in #665 is met. None of the findings above block merge; the build.resolve() swap and the shared dedupe list are the two I'd most want picked up, either here or as immediate follow-ups, since both affect the durability of the harness for #666 and #583.

This review was drafted by @fpigeonjr's coding agent.

- test-app/esbuild/dedupe-angular-plugin.ts: use esbuild's own
  build.resolve() (with a pluginData re-entry guard) instead of Node's
  require.resolve, which forced the "require" export condition and
  picked rxjs's CommonJS entry over its ESM one. main.js: 474.58 kB (2
  "not ESM" warnings) -> 334.29 kB (0 warnings), still a single copy
  of rxjs and @angular/core.
- test-app/dedupe-packages.ts: extract the dedupe package list into a
  single shared module imported by both vitest.config.mts and
  dedupe-angular-plugin.ts, so the two configs can no longer drift
  (the esbuild list was missing @fortawesome/angular-fontawesome and
  @fortawesome/fontawesome-svg-core, which vitest.config.mts already
  depended on). Dropped zone.js from the shared list per review: no
  src/ file imports it, it only enters via the polyfills array, which
  esbuild already resolves from test-app.
- .github/workflows/e2e.yml: swap npm install --no-package-lock for
  npm ci on both workspaces, matching ci.yml, so the e2e job installs
  the exact pinned dependency versions instead of re-resolving them
  against unbounded peerDependency ranges on every run.
- test-app/e2e/tabs.spec.ts: replace the timing-coupled
  not.toBeVisible({ timeout: 100 }) assertion with a deterministic
  toHaveCSS("display", "none") check on the leaving tab body -
  asserts the cascade property GH-665's fix establishes directly,
  with no race against how fast the runner applies [hidden].
- AGENTS.md: document the Playwright component-render harness
  convention (one route per component, the tsconfig paths mapping,
  BrowserAnimationsModule, and why dedupe-angular-plugin.ts exists)
  so the next gallery route (e.g. #666) doesn't have to rediscover
  NG0203 from scratch.
- home.component.spec.ts/.ts: rename test names off the old
  AppComponent copy ("should create the app" -> "should create",
  "should render title in a h1 tag" -> "should render the title in an
  h1") and drop the empty home.component.css that came along with
  the rename.

Verified locally: npm --prefix test-app run test:e2e passes (and
fails against the unpatched tab-group.scss, confirming the new
assertion still gates the regression); npm --prefix test-app test
(1377 tests) and npm run coverage:check both still pass; npm run
build (test-app) still succeeds with the smaller esbuild bundle.
@fpigeonjr

Copy link
Copy Markdown
Contributor Author

Addressed all 5 "should verify" findings from the review in 1f2500d:

  1. require.resolve forcing rxjs to CJS — swapped to esbuild's own build.resolve() with a pluginData re-entry guard. Verified locally: main.js 474.58 kB (2 "not ESM" warnings) → 334.29 kB (0 warnings), still a single rxjs/@angular/core copy, e2e still green.
  2. Dedupe list drift — extracted the list into test-app/dedupe-packages.ts, imported by both vitest.config.mts and dedupe-angular-plugin.ts. This also fixed a real gap: the esbuild list was missing the two FontAwesome entries the Vitest list already had.
  3. --no-package-lock non-reproducibility — switched .github/workflows/e2e.yml to npm ci on both workspaces, matching ci.yml. (Didn't pursue dropping the root install step entirely — the AOT-typechecking TS2307 failure that motivated it is a separate, riskier change I'd rather not bundle here.)
  4. Timing-coupled e2e assertion — replaced not.toBeVisible({ timeout: 100 }) with a deterministic toHaveCSS("display", "none") check on the leaving md-tab-body. Confirmed it still fails against the unpatched CSS and passes with the fix.
  5. Undocumented harness convention — added a "Playwright component-render harness" section to AGENTS.md covering the one-route-per-component convention, the paths mapping, BrowserAnimationsModule, and why dedupe-angular-plugin.ts exists, so Fix sam-datepicker-v2 outside-click close and the tabindex/aria-hidden restore leak #666 doesn't have to rediscover NG0203 from scratch.

Also took the two 💡 suggestions:

  • Dropped zone.js from the shared dedupe list — confirmed no src/ file imports it directly; it only enters via the polyfills array, which esbuild already resolves from test-app.
  • Renamed the copied home.component.spec.ts test names off the old AppComponent wording and removed the empty home.component.css that came along with the rename.

Left the extract-i18n builder-family note as-is (cosmetic, no target exercises it).

Re-verified after all changes: npm --prefix test-app run test:e2e, npm --prefix test-app test (1377 tests), npm run coverage:check, npm run lint:baseline (both workspaces), and npm run format:check all pass.

@fpigeonjr
fpigeonjr merged commit 2f24750 into master Sep 1, 2026
7 checks passed
fpigeonjr added a commit that referenced this pull request Sep 1, 2026
master has been red since #670 merged. The failure is the coverage-floor
gate, not a test failure — all 1377 specs pass:

  statements  88.57% (floor 88.73%)
  branches    78.57% (floor 78.65%)
  functions   85.74% (floor 85.90%)
  lines       88.55% (floor 88.72%)

Cause is a merge-order collision between #668 and #670. #668 (merged
11:21) added the Playwright component-render harness, including two
test-app build-tooling files — dedupe-packages.ts and
esbuild/dedupe-angular-plugin.ts. Both run inside the bundler, so no
spec can ever execute them, but coverage.all walks the project and they
weren't in coverage.exclude, so they landed in the report at 0%
(14 statements, 4 branches, 4 functions, 14 lines of pure denominator).
#670 (merged 11:24) then locked the floor at coverage measured on a
branch cut from 081088f — before #668 — so its denominator never saw
those files. Each PR was green on its own; master got the union.

Extend coverage.exclude to cover both files, matching the rationale
already documented for src/main.ts, app.module.ts, environments/** and
playwright.config.ts: keep harness/build scaffolding that has nothing to
do with the library under test out of the denominator. Verified against
the failing run's coverage artifact that dropping exactly these two
files restores statements 88.73 / branches 78.65 / functions 85.90 /
lines 88.72 — at or above every floor, so coverage-floor.json is
untouched (and per AGENTS.md, lowering the floor to go green isn't an
option).

Refs #637
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix inactive tab content rendering on top of active tab in sam-tabs-next

3 participants