Skip to content

Commit c252de8

Browse files
puikinshclaude
andcommitted
fix(layout): empty v2 auth card, mobile header overflow, focusable collapsed submenus
LteAuthLayout declared the default <ng-content /> (and the [logo] slot) once per @if branch. Angular distributes projected nodes into a single slot at creation, so every variant="v2" page rendered an empty .card-body — /login, /login-v2 and /register-v2 shipped a blank card. Each slot is now declared exactly once: the default slot in the .card-body both variants share, [logo] in an <ng-template> that each variant stamps with ngTemplateOutlet. Rendered markup is unchanged. A collapsed .nav-treeview kept height: 0 with visibility: visible and no inert, so 19 invisible sidebar links stayed in the tab order. The animation wrapper now carries inert while closed, with a visibility: hidden delayed until the collapse transition ends, so the slide animation is untouched; the toggle gained aria-controls. The topbar's full toggle row measured 413px inside a 390px viewport (Bootstrap's 1rem navbar link padding), scrolling every page sideways. LteTopbar halves --bs-navbar-nav-link-padding-x below sm, declared on <nav class="app-header"> so projected [topbar-start]/[topbar-end] items inherit it. Now 390/390 and 320/320. The demo build sets baseHref/deployUrl to "/" so index.html emits absolute asset URLs: the relative <link rel="modulepreload"> resolved against the location, not <base>, so /dashboard/v2 fetched /dashboard/chunk-*.js, got Pages' index.html fallback and logged a text/html module MIME error on ~16 routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 461fe9a commit c252de8

5 files changed

Lines changed: 136 additions & 36 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,43 @@ All notable changes to `@adminlte/angular` are documented here. The format is ba
7878
therefore stay on 6.1.21 until the plugin set ships stable 7.x builds.
7979
- **Held back — TypeScript 7.** `@angular/compiler-cli@22.1.2` and `@angular/build@22.1.4`
8080
both declare `typescript: >=6.0 <6.1`, so the workspace stays on `~6.0.0`.
81+
82+
### Fixed
83+
84+
- **`LteAuthLayout` rendered an empty card for `variant="v2"` (breaking).** The template
85+
declared the default `<ng-content />` twice — once per `@if` branch — and Angular
86+
distributes projected nodes into a single slot when the component is created, so the
87+
copy in the losing branch never received anything. Every `variant="v2"` page therefore
88+
shipped `<div class="card-body login-card-body"></div>` with no children: the demo's
89+
`/login`, `/login-v2` and `/register-v2` showed a blank card, and the `[logo]` slot had
90+
the same defect. Both slots are now declared exactly once — the default slot in the
91+
`.card-body` both variants share, and `[logo]` in an `<ng-template>` that each variant
92+
stamps with `ngTemplateOutlet` — so `default` and `v2` project identically. The rendered
93+
markup is unchanged for consumers (`.login-box > .card.card-outline.card-primary >
94+
.card-header + .card-body` for v2, brand above a plain `.card` otherwise). Anyone
95+
extending the component: a projection slot may only be declared once per template.
96+
- **Sidebar: collapsed submenus stayed keyboard-focusable (a11y).** A closed
97+
`.nav-treeview` collapses to `height: 0` inside the grid animation wrapper, but stayed
98+
`visibility: visible` with no `inert`, so its links kept their place in the tab order —
99+
19 invisible links on the demo's default route, reachable with Tab and focusable via
100+
script. The wrapper now carries `inert` while closed (removing the subtree from both the
101+
tab order and the accessibility tree), backed by a `visibility: hidden` that is delayed
102+
until the collapse transition ends, so the slide-open/slide-shut animation is unchanged.
103+
The group toggle also gained `aria-controls` pointing at the submenu's new `id`.
104+
- **Header overflowed the viewport on phones.** With the standard toggle row (search,
105+
messages, notifications, tasks, fullscreen, colour mode, user menu), Bootstrap's 1rem
106+
navbar link padding made the header 413 px wide inside a 390 px viewport, so every page
107+
scrolled sideways (`scrollWidth` 413 vs `clientWidth` 390). `LteTopbar` now halves
108+
`--bs-navbar-nav-link-padding-x` below the `sm` breakpoint; the property is declared on
109+
`<nav class="app-header">`, so it also applies to items projected through
110+
`[topbar-start]` / `[topbar-end]`. Measured 390/390 at 390 px and 320/320 at 320 px;
111+
layout at `sm` and above is untouched.
112+
- **Demo: a MIME-type console error on every two-segment route.** The demo build now sets
113+
`baseHref` / `deployUrl` to `/`, so `index.html` emits absolute asset URLs (`/main-*.js`,
114+
`/chunk-*.js`, `/styles-*.css`). The relative `<link rel="modulepreload"
115+
href="chunk-*.js">` was resolved against the current location rather
116+
than `<base href="/">` by the browser's speculative preloader, so on a two-segment route
117+
such as `/dashboard/v2` it fetched `/dashboard/chunk-*.js`; Cloudflare Pages' SPA
118+
fallback answered with `index.html` and Chrome logged *"Failed to load module script:
119+
Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of
120+
text/html"* on ~16 routes. Absolute URLs resolve identically from any depth.

‎angular.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
"outputPath": "dist/demo",
4646
"index": "projects/demo/src/index.html",
4747
"browser": "projects/demo/src/main.ts",
48+
"baseHref": "/",
49+
"deployUrl": "/",
4850
"polyfills": ["zone.js"],
4951
"tsConfig": "projects/demo/tsconfig.app.json",
5052
"assets": [

‎projects/adminlte-angular/src/lib/layout/auth-layout.component.ts‎

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,54 +8,59 @@ import {
88
effect,
99
input,
1010
} from '@angular/core';
11-
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
11+
import { DOCUMENT, NgTemplateOutlet, isPlatformBrowser } from '@angular/common';
1212
import { RouterLink } from '@angular/router';
1313

1414
/**
1515
* Centered card layout for login / register pages. Applies the page-level body
1616
* classes (`login-page` / `register-page` + `bg-body-secondary`) while mounted
1717
* and removes them on destroy. Project the form into the default slot, and
1818
* optionally a custom brand into `[logo]`.
19+
*
20+
* IMPORTANT — this template must declare each projection slot exactly ONCE.
21+
* Angular distributes the projected nodes into a single slot when the component
22+
* is created; a second `<ng-content />` with the same selector (e.g. one per
23+
* `@if` branch) never receives anything, so whichever branch holds the losing
24+
* copy renders an empty card. That is what happened to every `variant="v2"`
25+
* page before this was fixed. The brand block therefore lives in a
26+
* `<ng-template>` that both variants stamp with `ngTemplateOutlet`, and the
27+
* default slot lives in the `.card-body`, which both variants share.
1928
*/
2029
@Component({
2130
selector: 'lte-auth-layout',
2231
changeDetection: ChangeDetectionStrategy.OnPush,
23-
imports: [RouterLink],
32+
imports: [RouterLink, NgTemplateOutlet],
2433
template: `
34+
<ng-template #brand>
35+
<a [routerLink]="logoHref()" [class]="brandLinkClass()">
36+
@if (logo()) {
37+
<img [src]="logo()" alt="Logo" height="48" />
38+
} @else if (variant() === 'v2') {
39+
<h1 class="mb-0"><b>Admin</b>LTE</h1>
40+
} @else {
41+
<b>Admin</b>LTE
42+
}
43+
<ng-content select="[logo]" />
44+
</a>
45+
</ng-template>
46+
2547
<div [class]="authType() + '-box'">
26-
@if (variant() === 'v2') {
27-
<div class="card card-outline card-primary">
28-
<div class="card-header">
29-
<a [routerLink]="logoHref()" class="link-dark text-center link-offset-2 link-opacity-100 link-opacity-50-hover">
30-
@if (logo()) {
31-
<img [src]="logo()" alt="Logo" height="48" />
32-
} @else {
33-
<h1 class="mb-0"><b>Admin</b>LTE</h1>
34-
}
35-
<ng-content select="[logo]" />
36-
</a>
37-
</div>
38-
<div [class]="'card-body ' + authType() + '-card-body'">
39-
<ng-content />
40-
</div>
41-
</div>
42-
} @else {
48+
@if (variant() !== 'v2') {
4349
<div [class]="authType() + '-logo'">
44-
<a [routerLink]="logoHref()">
45-
@if (logo()) {
46-
<img [src]="logo()" alt="Logo" height="48" />
47-
} @else {
48-
<b>Admin</b>LTE
49-
}
50-
<ng-content select="[logo]" />
51-
</a>
50+
<ng-container [ngTemplateOutlet]="brand" />
5251
</div>
53-
<div class="card">
54-
<div [class]="'card-body ' + authType() + '-card-body'">
55-
<ng-content />
52+
}
53+
54+
<div [class]="cardClass()">
55+
@if (variant() === 'v2') {
56+
<div class="card-header">
57+
<ng-container [ngTemplateOutlet]="brand" />
5658
</div>
59+
}
60+
<div [class]="cardBodyClass()">
61+
<ng-content />
5762
</div>
58-
}
63+
</div>
5964
</div>
6065
`,
6166
})
@@ -70,6 +75,20 @@ export class AuthLayoutComponent {
7075

7176
private readonly bodyClasses = computed(() => [`${this.authType()}-page`, 'bg-body-secondary']);
7277

78+
/** `.card` chrome: v2 is the outlined primary card, default is the plain one. */
79+
readonly cardClass = computed(() =>
80+
this.variant() === 'v2' ? 'card card-outline card-primary' : 'card',
81+
);
82+
83+
readonly cardBodyClass = computed(() => `card-body ${this.authType()}-card-body`);
84+
85+
/** v2 renders the brand inside `.card-header`, so it needs the muted link styles. */
86+
readonly brandLinkClass = computed(() =>
87+
this.variant() === 'v2'
88+
? 'link-dark text-center link-offset-2 link-opacity-100 link-opacity-50-hover'
89+
: '',
90+
);
91+
7392
constructor(
7493
@Inject(PLATFORM_ID) platformId: object,
7594
@Inject(DOCUMENT) private readonly doc: Document,

‎projects/adminlte-angular/src/lib/layout/sidebar-nav-item.component.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ let uid = 0;
2525
* highlight and the menu link colour (`display: contents` does not help, since
2626
* it only affects layout, not selector matching). Active-link detection compares
2727
* the item's route/href to `currentPath`; groups auto-open when a descendant is
28-
* active.
28+
* active. A collapsed group's wrapper carries `inert`, so its links leave both
29+
* the tab order and the accessibility tree while the submenu is closed.
2930
*/
3031
@Component({
3132
selector: 'lte-sidebar-nav-item',
@@ -54,6 +55,18 @@ let uid = 0;
5455
display: block;
5556
overflow: hidden;
5657
min-height: 0;
58+
/* A zero-height submenu is invisible but still rendered, so its links
59+
stayed keyboard-reachable. Visibility is delayed until the collapse
60+
animation has finished (and applied instantly on open), which keeps
61+
the slide intact while taking the links out of the tab order. The
62+
inert attribute on the wrapper does the same immediately for the
63+
a11y tree; this rule is the belt-and-braces half. */
64+
visibility: hidden;
65+
transition: visibility 0s linear var(--lte-treeview-speed, 300ms);
66+
}
67+
.treeview-wrap.open > .nav-treeview {
68+
visibility: visible;
69+
transition-delay: 0s;
5770
}
5871
`,
5972
],
@@ -90,7 +103,14 @@ let uid = 0;
90103
}
91104
}
92105
@default {
93-
<a href="#" class="nav-link" [class.active]="groupActive()" [attr.aria-expanded]="isOpen()" (click)="toggle($event)">
106+
<a
107+
href="#"
108+
class="nav-link"
109+
[class.active]="groupActive()"
110+
[attr.aria-expanded]="isOpen()"
111+
[attr.aria-controls]="id + '-submenu'"
112+
(click)="toggle($event)"
113+
>
94114
@if (group().icon) {
95115
<i class="nav-icon {{ icon(group().icon) }}"></i>
96116
}
@@ -103,8 +123,13 @@ let uid = 0;
103123
</p>
104124
</a>
105125
106-
<div class="treeview-wrap" [class.open]="isOpen()" [style.--lte-treeview-speed.ms]="animationSpeed()">
107-
<ul class="nav nav-treeview">
126+
<div
127+
class="treeview-wrap"
128+
[class.open]="isOpen()"
129+
[style.--lte-treeview-speed.ms]="animationSpeed()"
130+
[attr.inert]="isOpen() ? null : ''"
131+
>
132+
<ul class="nav nav-treeview" [id]="id + '-submenu'">
108133
@for (child of visibleChildren(); track trackChild($index, child)) {
109134
<lte-sidebar-nav-item
110135
[item]="child"

‎projects/adminlte-angular/src/lib/layout/topbar.component.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,21 @@ import type { TopbarUser } from '../types/layout';
2424
imports: [ColorModeToggleComponent, FullscreenToggleComponent],
2525
// The host is a grid item of .app-wrapper; make it transparent so the inner
2626
// <nav class="app-header"> becomes the real grid item (grid-area: lte-app-header).
27-
styles: ':host { display: contents; }',
27+
styles: `
28+
:host { display: contents; }
29+
30+
/* Phones: Bootstrap's navbar links are padded 1rem a side, which makes a
31+
full toggle row (search, messages, notifications, tasks, fullscreen,
32+
colour mode, user menu) 413px wide inside a 390px viewport — the page
33+
then scrolls sideways. Halving the padding below 'sm' keeps every item
34+
on screen (296px at 390px wide, still fitting a 320px viewport). The
35+
custom property is declared on <nav class="app-header"> so it inherits
36+
into items projected through [topbar-start] / [topbar-end] too, which a
37+
scoped descendant selector could not reach. */
38+
@media (max-width: 575.98px) {
39+
.app-header { --bs-navbar-nav-link-padding-x: 0.5rem; }
40+
}
41+
`,
2842
template: `
2943
<nav [class]="navClass()">
3044
<div class="container-fluid">

0 commit comments

Comments
 (0)