diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 99dae48ed..575d6fc3f 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -3,7 +3,7 @@ name: Publish @sdcorejs/angular to npm on: push: tags: - - 'v2.7' + - 'v2.8' workflow_dispatch: inputs: artifact_run_id: @@ -15,7 +15,7 @@ on: permissions: {} concurrency: - group: publish-npm-v2.7 + group: publish-npm-v2.8 cancel-in-progress: false jobs: @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && 'refs/tags/v2.7' || github.ref }} + ref: ${{ github.event_name == 'workflow_dispatch' && 'refs/tags/v2.8' || github.ref }} - name: Require the release tag commit to be on main run: | @@ -52,7 +52,7 @@ jobs: id: release_plan run: | echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - node --input-type=module -e "import {releaseTargets} from './scripts/release-package-contract.mjs';process.stdout.write('targets_json='+JSON.stringify(releaseTargets('2.7'))+'\n')" >> "$GITHUB_OUTPUT" + node --input-type=module -e "import {releaseTargets} from './scripts/release-package-contract.mjs';process.stdout.write('targets_json='+JSON.stringify(releaseTargets('2.8'))+'\n')" >> "$GITHUB_OUTPUT" - name: Pin npm used by the release contract run: | @@ -227,7 +227,7 @@ jobs: if: ${{ inputs.artifact_run_id != '' }} uses: actions/download-artifact@v4 with: - name: sdcorejs-angular-${{ matrix.version }}-2.7 + name: sdcorejs-angular-${{ matrix.version }}-2.8 path: release-stage github-token: ${{ github.token }} repository: ${{ github.repository }} @@ -236,7 +236,7 @@ jobs: - name: Upload immutable package artifact uses: actions/upload-artifact@v4 with: - name: sdcorejs-angular-${{ matrix.version }}-2.7 + name: sdcorejs-angular-${{ matrix.version }}-2.8 path: release-stage if-no-files-found: error retention-days: 14 @@ -268,7 +268,7 @@ jobs: - name: Download every package artifact uses: actions/download-artifact@v4 with: - pattern: sdcorejs-angular-v*-2.7 + pattern: sdcorejs-angular-v*-2.8 path: release-artifacts merge-multiple: true @@ -276,7 +276,7 @@ jobs: run: echo "verify artifact workspaces v19 v20 v21 v22 with sha256 integrity shasum" - name: Validate baselines and compile unchanged strict consumers - run: node scripts/release-package-contract.mjs --artifact-root release-artifacts --suffix 2.7 --baseline-suffix 2.6 --datetime-version 1.0.4 --compile-consumers + run: node scripts/release-package-contract.mjs --artifact-root release-artifacts --suffix 2.8 --baseline-suffix 2.7 --datetime-version 1.0.4 --compile-consumers publish-packages: name: Publish the verified transaction @@ -311,12 +311,12 @@ jobs: - name: Download the already verified package artifacts uses: actions/download-artifact@v4 with: - pattern: sdcorejs-angular-v*-2.7 + pattern: sdcorejs-angular-v*-2.8 path: release-artifacts merge-multiple: true - name: Execute one fail-closed publish transaction - run: node scripts/release-package-contract.mjs --artifact-root release-artifacts --suffix 2.7 --baseline-suffix 2.6 --datetime-version 1.0.4 --publish --require-provenance + run: node scripts/release-package-contract.mjs --artifact-root release-artifacts --suffix 2.8 --baseline-suffix 2.7 --datetime-version 1.0.4 --publish --require-provenance publish-docs-and-page: name: Publish verified docs and static page @@ -346,7 +346,7 @@ jobs: - name: Download the verified package artifacts uses: actions/download-artifact@v4 with: - pattern: sdcorejs-angular-v*-2.7 + pattern: sdcorejs-angular-v*-2.8 path: release-artifacts merge-multiple: true @@ -354,7 +354,7 @@ jobs: shell: bash run: | set -euo pipefail - node scripts/release-package-contract.mjs --artifact-root release-artifacts --suffix 2.7 --baseline-suffix 2.6 --datetime-version 1.0.4 > verified-release.json + node scripts/release-package-contract.mjs --artifact-root release-artifacts --suffix 2.8 --baseline-suffix 2.7 --datetime-version 1.0.4 > verified-release.json V19_TARBALL="$(find release-artifacts/v19 -type f -name '*.tgz' -print -quit)" test -n "$V19_TARBALL" sha256sum "$V19_TARBALL" @@ -366,15 +366,15 @@ jobs: run: npm --prefix showcase ci --legacy-peer-deps - name: Generate all four API-doc archives - run: npm run collect-release-docs -- --patch 2.7 --date "$(date -u +%F)" --commit "${{ needs.verify_source.outputs.source_sha }}" --skip-existing + run: npm run collect-release-docs -- --patch 2.8 --date "$(date -u +%F)" --commit "${{ needs.verify_source.outputs.source_sha }}" --skip-existing - name: Build and retain static release page shell: bash run: | - npm run build:page -- --suffix 2.7 - test -d published-pages/2.7 + npm run build:page -- --suffix 2.8 + test -d published-pages/2.8 test ! -d published-pages/2.2 - node -e "const p=require('./published-pages/pages.json');const retained2x=p.suffixes.filter(s=>s.startsWith('2.'));if(p.latest!=='2.7'||retained2x.join(',')!=='2.7,2.6,2.5,2.4,2.3'||!p.suffixes.some(s=>s.startsWith('1.')))throw new Error('published-pages retention mismatch')" + node -e "const p=require('./published-pages/pages.json');const retained2x=p.suffixes.filter(s=>s.startsWith('2.'));if(p.latest!=='2.8'||retained2x.join(',')!=='2.8,2.7,2.6,2.5,2.4'||!p.suffixes.some(s=>s.startsWith('1.')))throw new Error('published-pages retention mismatch')" - name: Commit generated docs and page shell: bash @@ -383,7 +383,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add published-docs published-pages - git commit -m "docs: publish Angular 22 release 2.7" + git commit -m "docs: publish Angular 22 release 2.8" SOURCE_SHA="${{ needs.verify_source.outputs.source_sha }}" test "$(git rev-parse HEAD^)" = "$SOURCE_SHA" git fetch origin main --no-tags diff --git a/.sdcorejs/docs/angular/loading-overlay-2.8/@sdcorejs+angular+20.2.8.patch b/.sdcorejs/docs/angular/loading-overlay-2.8/@sdcorejs+angular+20.2.8.patch new file mode 100644 index 000000000..12343914b --- /dev/null +++ b/.sdcorejs/docs/angular/loading-overlay-2.8/@sdcorejs+angular+20.2.8.patch @@ -0,0 +1,131 @@ +diff --git a/node_modules/@sdcorejs/angular/configurations/index.d.ts b/node_modules/@sdcorejs/angular/configurations/index.d.ts +index 1156d4f..54cb6a4 100644 +--- a/node_modules/@sdcorejs/angular/configurations/index.d.ts ++++ b/node_modules/@sdcorejs/angular/configurations/index.d.ts +@@ -1,5 +1,6 @@ + import { InjectionToken } from '@angular/core'; + import { Language } from '@sdcorejs/utils/models'; ++import type { ISdLayoutConfiguration } from '@sdcorejs/angular/modules/layout'; + + interface ISdCoreConfiguration { + format?: { +@@ -8,6 +9,7 @@ interface ISdCoreConfiguration { + language?: Language | (() => Record); + } + declare const SD_CORE_CONFIGURATION: InjectionToken; ++declare const SD_LAYOUT_CONFIGURATION: InjectionToken; + +-export { SD_CORE_CONFIGURATION }; ++export { SD_CORE_CONFIGURATION, SD_LAYOUT_CONFIGURATION }; + export type { ISdCoreConfiguration }; +diff --git a/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-components-tab-router.mjs b/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-components-tab-router.mjs +index fe3a185..cc9246f 100644 +--- a/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-components-tab-router.mjs ++++ b/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-components-tab-router.mjs +@@ -420,6 +420,12 @@ class SdTabRouterOutletComponent { + await this.#activeRoute(fullUrl, route, state); + }); + }; ++ // A route may opt into one tab per path when query parameters are view state. ++ // Other routes retain the package's URL + query-parameter identity. ++ #tabKey = (url, route) => Utilities.hash({ ++ url, ++ queryParams: route.data?.['tabIdentity'] === 'path' ? {} : { ...(route.queryParams || {}) }, ++ }); + // Bù initial navigation nếu NavigationEnd đã fire trước khi outlet subscribe. + #syncCurrentRoute = () => { + if (this.disabled() || !this.#router.navigated) { +@@ -430,10 +436,7 @@ class SdTabRouterOutletComponent { + return Promise.resolve(); + } + const [urlPath] = this.#router.url.split('?'); +- const key = Utilities.hash({ +- url: urlPath, +- queryParams: { ...(route.queryParams || {}) }, +- }); ++ const key = this.#tabKey(urlPath, route); + if (this.tabs().some(tab => tab.key === key)) { + return Promise.resolve(); + } +@@ -492,8 +495,8 @@ class SdTabRouterOutletComponent { + const params = { ...(route.params || {}) }; + const data = { ...(route.data || {}) }; + const [url] = fullUrl.split('?'); +- // Tab identity = hash(url + queryParams). Cùng key mặc định giữ tab cũ; forceReload mới tạo lại. +- const key = Utilities.hash({ url, queryParams }); ++ // Query identity is unchanged unless this route explicitly opts into path identity. ++ const key = this.#tabKey(url, route); + let existedIndex = -1; + let activatedIndex = -1; + const currentTabs = this.tabs(); +@@ -578,8 +581,12 @@ class SdTabRouterOutletComponent { + // re-evaluate không cần thiết). Với tab cần đổi isActive: tạo object mới qua spread, + // các nested fields (component, injector) vẫn giữ same reference nên component không bị recreate. + let updatedTabs = currentTabs.map(tab => { +- if (tab.key === key) ++ if (tab.key === key) { ++ // Preserve the component injector and its view state while keeping tab links current. ++ if (data['tabIdentity'] === 'path') ++ return { ...tab, isActive: true, queryParams }; + return tab.isActive ? tab : { ...tab, isActive: true }; ++ } + return tab.isActive ? { ...tab, isActive: false } : tab; + }); + // replaceTab: thay vì mở tab mới song song, xoá tab đang active rồi mở tab mới ở cuối. +diff --git a/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-configurations.mjs b/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-configurations.mjs +index a634ad1..177373e 100644 +--- a/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-configurations.mjs ++++ b/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-configurations.mjs +@@ -1,10 +1,11 @@ + import { InjectionToken } from '@angular/core'; + + const SD_CORE_CONFIGURATION = new InjectionToken('sd-core.configuration'); ++const SD_LAYOUT_CONFIGURATION = new InjectionToken('sd.layout.configuration'); + + /** + * Generated bundle index. Do not edit. + */ + +-export { SD_CORE_CONFIGURATION }; ++export { SD_CORE_CONFIGURATION, SD_LAYOUT_CONFIGURATION }; + //# sourceMappingURL=sdcorejs-angular-configurations.mjs.map +diff --git a/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-modules-layout.mjs b/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-modules-layout.mjs +index bcc004f..17464fa 100644 +--- a/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-modules-layout.mjs ++++ b/node_modules/@sdcorejs/angular/fesm2022/sdcorejs-angular-modules-layout.mjs +@@ -28,7 +28,8 @@ import { SdIcon } from '@sdcorejs/angular/modules/icon'; + import { isObservable } from 'rxjs'; + import { SdInput } from '@sdcorejs/angular/forms'; + import * as i1$2 from '@angular/cdk/a11y'; + import { A11yModule } from '@angular/cdk/a11y'; ++import { SD_LAYOUT_CONFIGURATION } from '@sdcorejs/angular/configurations'; + + class SdPageComponent { + el = inject(ElementRef); +@@ -336,7 +337,6 @@ function normalizeSidebarConfiguration(sidebar) { + } + return sidebar; + } +-const SD_LAYOUT_CONFIGURATION = new InjectionToken('sd.layout.configuration'); + + const SD_LAYOUT_VIEWPORT = SD_VIEWPORT; + /** @deprecated Prefer `SdViewportService` from `@sdcorejs/angular/services/viewport`. */ +diff --git a/node_modules/@sdcorejs/angular/modules/layout/index.d.ts b/node_modules/@sdcorejs/angular/modules/layout/index.d.ts +index 06f2ad0..5248797 100644 +--- a/node_modules/@sdcorejs/angular/modules/layout/index.d.ts ++++ b/node_modules/@sdcorejs/angular/modules/layout/index.d.ts +@@ -1,5 +1,6 @@ + import * as _angular_core from '@angular/core'; + import { InjectionToken, Signal, PipeTransform } from '@angular/core'; ++import { SD_LAYOUT_CONFIGURATION } from '@sdcorejs/angular/configurations'; + import * as i1$1 from '@angular/common'; + import * as i1 from '@angular/router'; + import { Params } from '@angular/router'; +@@ -272,7 +273,6 @@ declare function resolveSidebarV3Recent(sidebar: SidebarConfigurationV3): Resolv + declare function normalizeSidebarConfiguration(_sidebar: SidebarConfigurationV1): SidebarConfigurationV1; + declare function normalizeSidebarConfiguration(_sidebar: SidebarConfigurationV2): SidebarConfigurationV2; + declare function normalizeSidebarConfiguration(_sidebar: SidebarConfigurationV3): SidebarConfigurationV3; +-declare const SD_LAYOUT_CONFIGURATION: InjectionToken; + + declare class SdLayoutNavigationStateService { + #private; diff --git a/.sdcorejs/docs/angular/loading-overlay-2.8/README.md b/.sdcorejs/docs/angular/loading-overlay-2.8/README.md new file mode 100644 index 000000000..8e857449d --- /dev/null +++ b/.sdcorejs/docs/angular/loading-overlay-2.8/README.md @@ -0,0 +1,90 @@ +# Core UI loading overlay — bản vá 2.8 + +Ngày kiểm chứng: 2026-09-10. Repository sở hữu source và artifact: `sdcorejs-angular`. +Phạm vi: lỗi CSS giữa `SdLoadingService` và `SdButton`; chuẩn bị bản vá cho bốn Angular major. + +## Nguyên nhân và sửa tại nguồn + +Core UI 20.2.7 dùng `.sd-loading` cho cả trạng thái button lẫn overlay service. Rule global của service áp `position: absolute`, `inset: 0`, kích thước 100% và `z-index: 99999` lên button. Stylesheet còn sống sau `ref.close()` cho đến khi service owner cuối cùng bị destroy, nên chỉ đóng overlay không giải quyết xung đột. + +Sửa canonical `versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.ts`, rồi chạy `npm run sync`: + +- Gắn `data-sd-loading-overlay` khi service tạo overlay. +- Scope rule overlay thành `.sd-loading[data-sd-loading-overlay]`. +- Scope spinner thành `.sd-loading[data-sd-loading-overlay] > .sd-loading-spinner`. +- Đồng bộ kiểm tra rule trong cơ chế nhận diện/augment stylesheet. + +Không đổi code `SdButton`, API, class hiện hữu, keyframes, contribution/ref counting, ownership giữa các injector, phục hồi `aria-busy`, SSR hay cleanup đồng bộ. Preflight riêng loading fix giữ nguyên declarations, exports và danh sách file. Khi tích hợp `main` (`f53358d7`), candidate 2.8 còn bao gồm các sửa table, data-state và upload-file có sẵn trên main: upload-file bỏ input `appearance`/type liên quan theo mục Breaking của CHANGELOG. Việc bỏ API này không thuộc loading fix; Console cần bỏ binding upload appearance nếu đang dùng. + +Overlay ở vùng nội dung được định vị riêng vẫn để header và nút đóng drawer tương tác. `start()` mặc định phủ document body vẫn giữ hành vi overlay toàn trang hiện có; bản vá không đổi mô hình định vị/scroll của overlay. + +## Kiểm chứng + +### Sau khi tích hợp main + +- Full canonical Chrome: 5.336/5.336 pass. +- Build/pack bốn dòng và `check:sync`, 158 script tests: pass. +- Package-contract và strict consumer compile của cả bốn tarball tích hợp: pass. Patch layout/tab-router Console còn lại apply-check thành công trên build mới. +- Snapshot 2.8 được tái sinh từ build ở `48629b1b`; exports và inventory giữ nguyên, khác biệt declaration chỉ thuộc upload-file đã được main ghi Breaking. + +### Preflight loading độc lập trước tích hợp + +- Red trước sửa: 4/8 test rendering mới fail. Header button dịch x từ khoảng 611px về 32px, rộng từ khoảng 75px thành 670px và nhận z-index 99999. +- Focused Chrome suite: 129/129 pass (loading lifecycle, rendering, button, side drawer). +- Full canonical v19: 5.333/5.333 pass, có coverage. Statements 81,61%; branches 71,54%; functions 79,93%; lines 82,73%; vượt toàn bộ threshold. +- Full Chrome suite trên từng dòng v20, v21, v22: mỗi dòng 5.333/5.333 pass. Tổng bốn dòng: 21.332 test pass. +- Test rendering lưu/khôi phục scroll của document dùng chung trong Karma. Trong lần chạy full đầu, test trước để scrollY=897 khiến overlay absolute ngoài viewport; fixture đã được sửa để đo tại viewport xác định. Không đổi production CSS để xử lý trạng thái test. +- `npm run test:scripts`: 158 test pass. +- `npm run lint:release`: cả v19/v20/v21/v22 pass. Spec rendering cuối cùng cũng lint riêng trên cả bốn dòng. +- `npm run check:sync`: pass. +- `scripts/deploy.ps1 -PatchVersion 2.8 -SkipInstall -DryRun`: build/pack đủ bốn dòng, kiểm tra integrity/shasum/SHA-256 và xác nhận registry chưa có collision. +- Showcase production build: pass; có warning CommonJS từ dependencies hiện hữu. +- Showcase Chrome suite: 206/206 pass. +- Release package-contract: pass cho cả bốn tarball, gồm hash/manifest/declarations/sourcemap và strict Angular consumer compile (`consumersCompiled: true`). Local build tái sử dụng dependencies hiện có với Node 22.22.3; clean workspace install theo lockfile vẫn thuộc CI release. Consumer checks tạo môi trường cài đặt riêng. +- Patch Console đính kèm đã qua `git apply --check` trên các file từ build v20.2.8. + +Final checks: `git diff --check` và scan mojibake trên các file thay đổi đều pass. Không còn test thất bại hoặc blocker kỹ thuật trong các gate local đã chạy. + +Logs local nằm trong `tmp/` (git-ignored). Các lần red/diagnostic được giữ riêng với kết quả final. + +## Nâng cấp Enterprise Console + +Áp dụng sau khi transaction npm `v2.8` đã postpublish GREEN và xác minh được `@sdcorejs/angular@20.2.8`. Các lệnh dưới chạy trong checkout Console chứa hotfix `4a36177`; chưa thay đổi checkout Console trong nhiệm vụ này. + +1. Cập nhật dependency và lockfile, tạm không chạy postinstall vì guard hiện pin 20.2.7: + + ```powershell + npm install --save-exact @sdcorejs/angular@20.2.8 --ignore-scripts + ``` + +2. Thay `patches/@sdcorejs+angular+20.2.7.patch` bằng file [`@sdcorejs+angular+20.2.8.patch`](@sdcorejs+angular+20.2.8.patch) đính kèm. File này lấy từ commit `4a36177`, bỏ riêng toàn bộ diff block của `fesm2022/sdcorejs-angular-services-loading.mjs`, giữ nguyên năm block còn lại: + + - `configurations/index.d.ts` + - `fesm2022/sdcorejs-angular-configurations.mjs` + - `fesm2022/sdcorejs-angular-modules-layout.mjs` + - `modules/layout/index.d.ts` + - `fesm2022/sdcorejs-angular-components-tab-router.mjs` + + Xóa file patch mang version 20.2.7 sau khi đã đặt file 20.2.8 vào đúng thư mục. Nếu Console đã bổ sung patch sau `4a36177`, rebase các hunk bổ sung thay vì ghi đè chúng bằng bản đính kèm. + +3. Trong `scripts/apply-core-ui-patches.mjs`, đổi guard thành `core.version !== "20.2.8"`; sửa thông báo thành layout/tab-router patches target Core UI 20.2.8. Giữ `--error-on-fail` và postinstall hook. Cập nhật `patches/README.md` để ghi loading fix đã thuộc upstream, layout/tab-router vẫn cần giữ. + +4. Kiểm tra cài sạch và regression trước khi deploy: + + ```powershell + npm ci + npm run test:unit -- --include=src/app/console/console-loading-layout.spec.ts --include=src/app/shell/main.component.spec.ts + npm run build + ``` + + Giữ test loading-layout hiện có. Chạy thêm regression tenant/tab identity của Console và full checks theo quy trình Console. Không tăng budget production bundle để né lỗi layout token. + +## Phiên bản và trạng thái phát hành + +- Candidate: `19.2.8`, **`20.2.8` cho Console**, `21.2.8`, `22.2.8`. +- Đã chuẩn bị package manifests, CHANGELOG section 2.8, workflow pin `v2.8`, baseline 2.7 và snapshot `scripts/release-contracts/2.8.json`. Không sửa archive `published-docs`/`published-pages`. +- Local tarballs: `C:/Users/Admin/AppData/Local/Temp/sdcorejs-angular-loading-main-2.8/v{19,20,21,22}/`. +- SHA-256 tarball `sdcorejs-angular-20.2.8.tgz`: `6388c80a5d50bc19b3835a891854a7f3160b93eb777740b41a8710ec36656997`. +- Local staging được build từ commit tích hợp `48629b1b`, chứa loading fix `e2b324c8` và main `f53358d7`. CI phải build lại từ release commit được gắn tag. +- Source loading đã commit (`e2b324c8`) và được chuẩn bị push/merge theo yêu cầu người dùng. **Chưa tạo tag hay publish npm.** Chưa nâng cấp/deploy Console; npm 20.2.8 chỉ được xem là phát hành sau postpublish GREEN. +- Bước phát hành tiếp theo: merge bản tích hợp vào `main`, tạo/push `v2.8`, để trusted-publishing CI verify/build/publish bốn dòng. Chỉ sau postpublish GREEN mới sinh docs/page và triển khai nâng cấp Console. diff --git a/AGENTS.md b/AGENTS.md index 2409e8fdb..c982a4307 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,12 +103,12 @@ cd ../.. && npm run build:page -- --suffix 1.6 ## Release -Release đang chuẩn bị: `v2.7` → `19.2.7` / `20.2.7` / `21.2.7` / `22.2.7`. -Workflow `publish-npm.yml` pin đúng tag `v2.7`; snapshot `scripts/release-contracts/2.7.json` -đối chiếu từng line với bản `*.2.6` cùng Angular major. Fallback từ Angular 22 sang 21 +Release đang chuẩn bị: `v2.8` → `19.2.8` / `20.2.8` / `21.2.8` / `22.2.8`. +Workflow `publish-npm.yml` pin đúng tag `v2.8`; snapshot `scripts/release-contracts/2.8.json` +đối chiếu từng line với bản `*.2.7` cùng Angular major. Fallback từ Angular 22 sang 21 chỉ áp dụng cho release khởi đầu `22.2.5`. -Tag `v` (vd `v2.7`) → CI chuẩn bị `19.2.7` / `20.2.7` / `21.2.7` / `22.2.7`, rồi sinh `published-docs` và commit về `main` sau khi toàn bộ transaction thành công. +Tag `v` (vd `v2.8`) → CI chuẩn bị `19.2.8` / `20.2.8` / `21.2.8` / `22.2.8`, rồi sinh `published-docs` và commit về `main` sau khi toàn bộ transaction thành công. Thứ tự bắt buộc: diff --git a/CHANGELOG.md b/CHANGELOG.md index a0406ee80..eef485053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changelog — `@sdcorejs/angular` +# Changelog — `@sdcorejs/angular` Changelog cho npm package `@sdcorejs/angular`, tập trung vào thay đổi public API, hành vi runtime, tài liệu sử dụng, tooling release, và các migration cần consumer chú ý. @@ -6,6 +6,18 @@ Format dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Maj ## [Unreleased] +## [2.8] - 2026-09-10 + +Release suffix `2.8` targets `19.2.8`, `20.2.8`, `21.2.8`, and `22.2.8`. + +### Fixed + +- Loading service overlay CSS now requires `.sd-loading[data-sd-loading-overlay]`; its spinner rules are scoped to the overlay's direct child. Loading `SdButton` hosts keep their header position and size, Material spinner and click protection. Existing classes, API, reference ownership, stylesheet sharing and synchronous cleanup remain unchanged. Chrome rendering regressions cover page/drawer headers, close-button hit testing, unrelated class names and overlapping refs/services. + +### Consumer upgrade + +- Enterprise Console: after `20.2.8` is published and verified, pin `@sdcorejs/angular` to `20.2.8`, update the lockfile and rebase `patch-package`. Remove only the `fesm2022/sdcorejs-angular-services-loading.mjs` diff block introduced by Console commit `4a36177`. Retain the configuration/layout token and tab-router hunks, rename the patch to `@sdcorejs+angular+20.2.8.patch`, and update the exact-version guard in `scripts/apply-core-ui-patches.mjs` to describe the remaining layout/tab-router patches. Verify clean install, Console loading-layout/shell regressions and the production bundle budget before deploying. + ### Changed - Table now contains quick search, rows and pagination in one white surface with 6px outer corners and 8px quick-search padding. Consumers no longer need an extra presentation wrapper; scrolling and sticky headers stay on the existing table scroll area. diff --git a/CLAUDE.md b/CLAUDE.md index d836d0439..cf19956f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,16 +108,16 @@ git push ### Quy trình deploy npm — qua GitHub Actions (khuyến nghị) -Release đang chuẩn bị: `v2.7` → `19.2.7` / `20.2.7` / `21.2.7` / `22.2.7`. -Workflow `publish-npm.yml` pin đúng tag `v2.7`; snapshot `scripts/release-contracts/2.7.json` -đối chiếu từng line với bản `*.2.6` cùng Angular major. Fallback từ Angular 22 sang 21 +Release đang chuẩn bị: `v2.8` → `19.2.8` / `20.2.8` / `21.2.8` / `22.2.8`. +Workflow `publish-npm.yml` pin đúng tag `v2.8`; snapshot `scripts/release-contracts/2.8.json` +đối chiếu từng line với bản `*.2.7` cùng Angular major. Fallback từ Angular 22 sang 21 chỉ áp dụng cho release khởi đầu `22.2.5`. Workflow: `.github/workflows/publish-npm.yml`. Auth qua **npm trusted publishing (OIDC)** — không dùng `NPM_TOKEN`, `NODE_AUTH_TOKEN` hay `npm login` local. Mọi release build/verify/publish job pin exact Node `22.22.3`; publisher cài exact `npm@11.5.1`, dùng job env `NPM_CONFIG_REGISTRY: https://registry.npmjs.org` và là job duy nhất có `permissions: id-token: write`. Không truyền `registry-url` cho `setup-node` trong job publisher vì action tự tạo token giả cùng `.npmrc` chứa `_authToken`, làm guard OIDC chặn publish. Trusted publisher trên npmjs.com pin theo repo + tên file workflow, nên đổi tên `publish-npm.yml` là phải khai báo lại bên npm. **Trigger**: - Push tag `v` → tạo đúng bốn version `19.` / `20.` / `21.` / `22.`. - - `v2.7` → `19.2.7`, `20.2.7`, `21.2.7`, `22.2.7`. + - `v2.8` → `19.2.8`, `20.2.8`, `21.2.8`, `22.2.8`. - Angular 22 bắt đầu tại `22.2.5`; generator/release plan không được dựng version 22 trước suffix `2.5`. - `@sdcorejs/angular-material-datetime@1.0.4` phải được publish và verify trước transaction Core UI `2.5`. @@ -134,10 +134,10 @@ Transaction không interleave build và publish: 5. Publish v22 cuối cùng với `latest`, rồi verify exact registry integrity/shasum/SHA-256 và provenance. Không rebuild, không `npm dist-tag add`. 6. Chỉ sau postpublish GREEN mới sinh đủ bốn `published-docs`, build `published-pages/`, áp retention và commit docs/page về `main`. -**Tag stable 19.2.7/20.2.7/21.2.7/22.2.7 sau khi release commit đã merge vào main**: +**Tag stable 19.2.8/20.2.8/21.2.8/22.2.8 sau khi release commit đã merge vào main**: ```bash -git tag v2.7 -git push origin v2.7 +git tag v2.8 +git push origin v2.8 ``` ### Release preflight local — `deploy.ps1` @@ -145,8 +145,8 @@ git push origin v2.7 Local script chỉ dùng để tái hiện build/pack/checksum; publication vẫn thuộc GitHub trusted-publishing workflow: ```powershell powershell -ExecutionPolicy Bypass -File ./scripts/deploy.ps1 ` - -PatchVersion "2.7" ` - -OutputPath "$env:TEMP\sdcorejs-angular-2.7" ` + -PatchVersion "2.8" ` + -OutputPath "$env:TEMP\sdcorejs-angular-2.8" ` -DryRun ``` diff --git a/scripts/publish-npm-workflow.test.mjs b/scripts/publish-npm-workflow.test.mjs index 373c9845e..c0ee86533 100644 --- a/scripts/publish-npm-workflow.test.mjs +++ b/scripts/publish-npm-workflow.test.mjs @@ -128,8 +128,8 @@ test('release workflow delegates the validated four-target plan to one sequentia assert.equal(publishInvocations.length, 1, 'publisher must use one direct, testable publish-transaction CLI invocation'); has(publishInvocations[0], /^node\s+scripts\/release-package-contract\.mjs\b/u); has(publishInvocations[0], /--artifact-root\s+\S+/u, 'publisher must revalidate the retained bundle'); - has(publishInvocations[0], /--suffix\s+["']?2\.7["']?/u); - has(publishInvocations[0], /--baseline-suffix\s+["']?2\.6["']?/u); + has(publishInvocations[0], /--suffix\s+["']?2\.8["']?/u); + has(publishInvocations[0], /--baseline-suffix\s+["']?2\.7["']?/u); has(publishInvocations[0], /--datetime-version\s+["']?1\.0\.4["']?/u); has(publishInvocations[0], /--require-provenance\b/u); has(publisher.source, /NPM_CONFIG_PREFER_ONLINE:\s*['"]true['"]/u, @@ -138,14 +138,14 @@ test('release workflow delegates the validated four-target plan to one sequentia lacks(commands, /npm dist-tag (?:add|set|rm)/u, 'release must not mutate dist-tags separately'); }); -test('every release entry path requires the immutable v2.7 tag to point at main', () => { +test('every release entry path requires the immutable v2.8 tag to point at main', () => { const verifySource = jobEntries(workflow).find(job => job.id === 'verify_source'); assert.ok(verifySource, 'verify_source job must exist'); has(workflow, /^\s{2}workflow_dispatch:\s*$/mu, 'manual recovery dispatch must remain available'); has( verifySource.source, - /ref:\s*\$\{\{\s*github\.event_name == 'workflow_dispatch' && 'refs\/tags\/v2\.7' \|\| github\.ref\s*\}\}/u, + /ref:\s*\$\{\{\s*github\.event_name == 'workflow_dispatch' && 'refs\/tags\/v2\.8' \|\| github\.ref\s*\}\}/u, 'manual dispatch must check out the immutable release tag', ); @@ -187,7 +187,7 @@ test('all four packages are built and verified as immutable artifacts before pub has(packerCommands, /shasum/iu); has(verifier.source, /actions\/download-artifact@/u); - has(verifierCommands, /--baseline-suffix\s+["']?2\.6["']?/u); + has(verifierCommands, /--baseline-suffix\s+["']?2\.7["']?/u); has(verifierCommands, /--datetime-version\s+["']?1\.0\.4["']?/u); has(verifierCommands, /(?:19|v19)[^\r\n]*(?:20|v20)[^\r\n]*(?:21|v21)[^\r\n]*(?:22|v22)/u); has(verifierCommands, /(?:sha256|integrity|shasum)/iu); @@ -269,7 +269,7 @@ test('manual recovery retains the original tarballs without repacking them', () assert.ok(packStep && recoveryStep && uploadStep); has(packStep, /^ if:\s*\$\{\{ inputs\.artifact_run_id == '' \}\}\s*$/mu); has(recoveryStep, /^ if:\s*\$\{\{ inputs\.artifact_run_id != '' \}\}\s*$/mu); - has(recoveryStep, /name:\s*sdcorejs-angular-\$\{\{ matrix\.version \}\}-2\.7/u); + has(recoveryStep, /name:\s*sdcorejs-angular-\$\{\{ matrix\.version \}\}-2\.8/u); has(recoveryStep, /run-id:\s*\$\{\{ inputs\.artifact_run_id \}\}/u); has(recoveryStep, /github-token:\s*\$\{\{ github\.token \}\}/u); has(recoveryStep, /repository:\s*\$\{\{ github\.repository \}\}/u); @@ -292,8 +292,8 @@ test('postpublish materializes verified v19, clean-installs Showcase and commits has(postpublishCommands, /(?:sha256|integrity)/iu); has(postpublishCommands, /npm --prefix showcase ci --legacy-peer-deps/u); has(postpublishCommands, /npm run collect-release-docs/u); - has(postpublishCommands, /npm run build:page -- --suffix ["']?2\.7["']?/u); - has(postpublishCommands, /published-pages\/2\.7/u); + has(postpublishCommands, /npm run build:page -- --suffix ["']?2\.8["']?/u); + has(postpublishCommands, /published-pages\/2\.8/u); has(postpublishCommands, /published-pages\/2\.2/u); has(postpublishCommands, /git add[^\r\n]*published-docs[^\r\n]*published-pages/u); @@ -303,7 +303,7 @@ test('postpublish materializes verified v19, clean-installs Showcase and commits lacks(postpublishCommands, /git rebase origin\/main/u); assertExactCommandSequence(postpublish.source, [ - 'git commit -m "docs: publish Angular 22 release 2.7"', + 'git commit -m "docs: publish Angular 22 release 2.8"', 'SOURCE_SHA="${{ needs.verify_source.outputs.source_sha }}"', 'test "$(git rev-parse HEAD^)" = "$SOURCE_SHA"', 'git fetch origin main --no-tags', @@ -338,8 +338,8 @@ test('postpublish parent guard cannot be replaced by a no-op command containing test('publisher revalidates exact versions, recovery tags, latest and provenance through the tested transaction', () => { const publisher = oneJobMatching(/release-package-contract\.mjs[^\r\n]*--publish\b/u, 'publisher'); const publisherCommands = executableCommands(publisher.source); - has(publisherCommands, /--suffix\s+["']?2\.7["']?/u); - has(publisherCommands, /--baseline-suffix\s+["']?2\.6["']?/u); + has(publisherCommands, /--suffix\s+["']?2\.8["']?/u); + has(publisherCommands, /--baseline-suffix\s+["']?2\.7["']?/u); has(publisherCommands, /--datetime-version\s+["']?1\.0\.4["']?/u); has(publisherCommands, /--require-provenance\b/u); }); diff --git a/scripts/release-contracts/2.8.json b/scripts/release-contracts/2.8.json new file mode 100644 index 000000000..56ee9a0e4 --- /dev/null +++ b/scripts/release-contracts/2.8.json @@ -0,0 +1,72 @@ +{ + "schemaVersion": 1, + "suffix": "2.8", + "reviewedSourceSha": "48629b1b44ad860e3075209688f0f9ea3fef3e50", + "artifactSource": "Local 2.8 candidate builds from the loading-overlay fix working tree based on 48629b1b44ad860e3075209688f0f9ea3fef3e50; Node 22.22.3. Compared against exact npm *.2.7 baselines: exports and file inventories unchanged. Loading API unchanged. Includes reviewed main table/upload/data-state changes; upload-file declaration removal is documented as breaking in CHANGELOG. Unpublished preflight artifacts; CI must rebuild from the release commit.", + "targets": { + "19.2.8": { + "version": "19.2.8", + "baselineVersion": "19.2.7", + "exports": { + "baseline": "10a73eb26c3856128552b3c9c4b76269dc15138f95ded4d081a8708a49d3f967", + "candidate": "10a73eb26c3856128552b3c9c4b76269dc15138f95ded4d081a8708a49d3f967" + }, + "files": { + "baseline": "84f9f2773785800887f080996a569e1d8509ce7cbaa38c884121d3f7bfac5c81", + "candidate": "84f9f2773785800887f080996a569e1d8509ce7cbaa38c884121d3f7bfac5c81" + }, + "publicSurface": { + "baseline": "a5eb5532a794c2f1157a3d267834a0b600ae8cb1ad311a82cc47d90f15e7f18c", + "candidate": "c5d73eea53d1b1fdbc3aa316846154d2b2606813412414a4b4c4c0f52e9d5e26" + } + }, + "20.2.8": { + "version": "20.2.8", + "baselineVersion": "20.2.7", + "exports": { + "baseline": "10a73eb26c3856128552b3c9c4b76269dc15138f95ded4d081a8708a49d3f967", + "candidate": "10a73eb26c3856128552b3c9c4b76269dc15138f95ded4d081a8708a49d3f967" + }, + "files": { + "baseline": "c4baac4a733cf9ba274dfe795a3309c599e8b3c59f2b3eba04b8d31c1ae1be95", + "candidate": "c4baac4a733cf9ba274dfe795a3309c599e8b3c59f2b3eba04b8d31c1ae1be95" + }, + "publicSurface": { + "baseline": "ced3a4d78b0080f83d7c6e11d128a64152f11a620c146e1fc9088d88ccc2a7c2", + "candidate": "07adf3c8e87081900880b9db6fbf51e7cae8c700f02998a3af0aa4f797f97d91" + } + }, + "21.2.8": { + "version": "21.2.8", + "baselineVersion": "21.2.7", + "exports": { + "baseline": "3d95cae9b4b9c4f70cea1cb2e557b203891ab331351f66b0b27823d79a6724e3", + "candidate": "3d95cae9b4b9c4f70cea1cb2e557b203891ab331351f66b0b27823d79a6724e3" + }, + "files": { + "baseline": "964bc74addc358648fca8db6cf22afc4b62eef44b0549614689fc24f65d250ac", + "candidate": "964bc74addc358648fca8db6cf22afc4b62eef44b0549614689fc24f65d250ac" + }, + "publicSurface": { + "baseline": "8823abc88cf4155011f213da6d3d824056771bf77a352a0d3fb6a82074e9a240", + "candidate": "549c527683d4efd413bcda00dc65ee1bd74fba2567e2e63610ee916fd6e911b3" + } + }, + "22.2.8": { + "version": "22.2.8", + "baselineVersion": "22.2.7", + "exports": { + "baseline": "3d95cae9b4b9c4f70cea1cb2e557b203891ab331351f66b0b27823d79a6724e3", + "candidate": "3d95cae9b4b9c4f70cea1cb2e557b203891ab331351f66b0b27823d79a6724e3" + }, + "files": { + "baseline": "964bc74addc358648fca8db6cf22afc4b62eef44b0549614689fc24f65d250ac", + "candidate": "964bc74addc358648fca8db6cf22afc4b62eef44b0549614689fc24f65d250ac" + }, + "publicSurface": { + "baseline": "f4dd6095324785899181acf6e094bdf58c9491f7e9f6b757fc76ac5b6d43b6e8", + "candidate": "b2c895717a92b894cec9019428d29107cc58de92a843a616c7c6ef2a7b60ab25" + } + } + } +} diff --git a/scripts/release-package-contract.test.mjs b/scripts/release-package-contract.test.mjs index 40d4256a2..90ab59ec6 100644 --- a/scripts/release-package-contract.test.mjs +++ b/scripts/release-package-contract.test.mjs @@ -374,7 +374,7 @@ test('release snapshot fingerprints ignore object insertion order but retain all }); test('repository snapshots bind reviewed releases to exact baselines without authorizing future releases', () => { - for (const suffix of ['2.5', '2.6', '2.7']) { + for (const suffix of ['2.5', '2.6', '2.7', '2.8']) { const contract = loadReleaseContract(suffix); for (const target of releaseTargets(suffix)) { const snapshot = contract.targets[target.version]; @@ -385,7 +385,7 @@ test('repository snapshots bind reviewed releases to exact baselines without aut } } } - assert.equal(loadReleaseContract('2.8'), undefined); + assert.equal(loadReleaseContract('2.9'), undefined); assert.throws(() => loadReleaseContract('../2.5')); }); diff --git a/versions/v19/SYNC-STATUS.md b/versions/v19/SYNC-STATUS.md index 1559e8956..4105a39e0 100644 --- a/versions/v19/SYNC-STATUS.md +++ b/versions/v19/SYNC-STATUS.md @@ -4,7 +4,7 @@ |-----|-------| | Angular Major | 19 | | Legacy Source Commit | d12478a1 | -| Updated At | 2026-09-10 17:28:25 | +| Updated At | 2026-09-10 21:30:26 | | Origin | repo-owned versions/v19 (final legacy sync vn-angular@d12478a1) | | Workspace Flow | versions/v19 | | Development Mode | repo-owned independent pack | diff --git a/versions/v19/projects/sdcorejs-angular/package.json b/versions/v19/projects/sdcorejs-angular/package.json index e6ad7533d..6b3d295f6 100644 --- a/versions/v19/projects/sdcorejs-angular/package.json +++ b/versions/v19/projects/sdcorejs-angular/package.json @@ -1,6 +1,6 @@ { "name": "@sdcorejs/angular", - "version": "19.2.7", + "version": "19.2.8", "description": "Angular component library: data table, query bar/builder, 20+ signal-based form controls, layout, auth and workflow services. Standalone, OnPush, signals-first. Published in parallel lines for Angular 19, 20, 21 and 22.", "keywords": [ "angular", diff --git a/versions/v19/projects/sdcorejs-angular/services/loading/sd-loading.md b/versions/v19/projects/sdcorejs-angular/services/loading/sd-loading.md index 267b93f85..bb6d22792 100644 --- a/versions/v19/projects/sdcorejs-angular/services/loading/sd-loading.md +++ b/versions/v19/projects/sdcorejs-angular/services/loading/sd-loading.md @@ -47,6 +47,8 @@ isLoading(selector = 'body'): Element | false | null; - Busy hosts receive `aria-busy="true"`; their previous value is restored after the final owner closes. - The overlay uses `role="status"`, `aria-live="polite"` and one spinner hidden from assistive technology. +- Service-created overlays carry `.sd-loading[data-sd-loading-overlay]`. Overlay layout rules target this ownership marker; spinner rules target only its direct `.sd-loading-spinner` child. The existing class names remain available. `SdButton` keeps its separate `.sd-loading` state, Material spinner and click blocking without receiving overlay positioning or sizing. +- Choose a positioned content host (for example a drawer's `.sd-side-drawer-body`) when header actions must remain interactive. The default `start()` covers the whole document body and intentionally blocks interactions beneath that overlay. - One shared `style[data-sd-loading-styles]` is maintained per document. The service adopts an existing consumer style element without deleting consumer-owned content. - Removing/reparenting an overlay is repaired on the next acquisition. - Injector teardown closes all owned refs, removes library-owned overlays/style content and clears bookkeeping. @@ -71,4 +73,4 @@ Existing balanced `start()` / `stop()` calls remain valid. For concurrency safet } ``` -Focused coverage lives in `loading.service.spec.ts`. +Focused lifecycle coverage lives in `loading.service.spec.ts`. `loading-rendering.spec.ts` runs real Chrome rendering through Karma: page/drawer header geometry, spinner and click behavior, close-button hit testing, foreign class isolation and overlapping service/ref cleanup. diff --git a/versions/v19/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts b/versions/v19/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts new file mode 100644 index 000000000..d5163242e --- /dev/null +++ b/versions/v19/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts @@ -0,0 +1,257 @@ +import { Component, EnvironmentInjector, createEnvironmentInjector, signal, viewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SdButton, SdButtonType } from '@sdcorejs/angular/components/button'; +import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer'; +import { SdPageComponent } from '../../../modules/layout/components/page/page.component'; +import { SdLoadingService } from './loading.service'; + +@Component({ + imports: [SdButton, SdSideDrawer, SdPageComponent], + template: ` + + +
Page content
+
+ + + Drawer content + + `, + styles: ` + :host { + display: block; + position: fixed; + inset: 32px; + } + :host ::ng-deep .c-page-header > div { + display: flex; + justify-content: space-between; + padding: 16px; + } + #loading-rendering-content { + position: relative; + height: 200px; + } + `, +}) +class LoadingRenderingHost { + readonly drawer = viewChild.required(SdSideDrawer); + readonly loading = signal(true); + type: SdButtonType = 'light'; + clicks = 0; + closes = 0; +} + +// why: Karma renders real component CSS in Chrome. DOM/class-only assertions cannot detect +// a global service selector changing the button's containing block or intercepting a drawer close. +describe('SdLoadingService and SdButton browser rendering', () => { + let fixture: ComponentFixture; + let service: SdLoadingService; + let previousScroll: { left: number; top: number }; + const injectors = new Set(); + const foreignNodes = new Set(); + + beforeEach(async () => { + previousScroll = { left: window.scrollX, top: window.scrollY }; + await TestBed.configureTestingModule({ imports: [LoadingRenderingHost, NoopAnimationsModule] }).compileComponents(); + fixture = TestBed.createComponent(LoadingRenderingHost); + service = TestBed.inject(SdLoadingService); + fixture.detectChanges(); + await fixture.whenStable(); + // why: các test focus/scroll khác dùng chung document của Karma; overlay absolute cần + // viewport ban đầu xác định để hit-test không phụ thuộc vị trí cuộn của test chạy trước. + window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); + }); + + afterEach(() => { + for (const injector of injectors) injector.destroy(); + injectors.clear(); + TestBed.resetTestingModule(); + for (const node of foreignNodes) node.remove(); + foreignNodes.clear(); + window.scrollTo({ ...previousScroll, behavior: 'instant' }); + }); + + function element(selector: string, root: ParentNode = document): HTMLElement { + const result = root.querySelector(selector); + if (!result) throw new Error(`Missing rendered element: ${selector}`); + return result; + } + + function expectRect(target: HTMLElement, before: DOMRect): void { + const after = target.getBoundingClientRect(); + for (const key of ['x', 'y', 'width', 'height'] as const) { + expect(after[key]) + .withContext(`${target.dataset['testid'] ?? target.className}: ${key}`) + .toBeCloseTo(before[key], 1); + } + expect(after.width).toBeGreaterThan(0); + expect(after.height).toBeGreaterThan(0); + expect(getComputedStyle(target).position).not.toBe('absolute'); + expect(getComputedStyle(target).zIndex).not.toBe('99999'); + } + + function hitCenter(target: HTMLElement): HTMLElement { + const rect = target.getBoundingClientRect(); + const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + if (!(hit instanceof HTMLElement)) throw new Error('No hit target at rendered center'); + return hit; + } + + function expectOverlayHit(target: HTMLElement, overlay: HTMLElement): void { + expect(hitCenter(target)) + .withContext( + JSON.stringify({ + overlay: overlay.getBoundingClientRect().toJSON(), + target: target.getBoundingClientRect().toJSON(), + scroll: [window.scrollX, window.scrollY], + position: getComputedStyle(document.body).position, + pointerEvents: getComputedStyle(overlay).pointerEvents, + }) + ) + .toBe(overlay); + } + + async function openDrawer(): Promise { + fixture.componentInstance.drawer().open(); + fixture.detectChanges(); + await fixture.whenStable(); + const drawer = element(`#${fixture.componentInstance.drawer().id}`); + // Finish the real CSS entrance transition before measuring, without timers or mocked layout. + for (const animation of drawer.getAnimations()) animation.finish(); + return drawer; + } + + it('keeps the page header button geometry across repeated document body overlay starts and stops', () => { + const button = element('[data-testid="page-action"]'); + const before = button.getBoundingClientRect(); + for (let cycle = 0; cycle < 2; cycle++) { + const ref = service.start(); + expectRect(button, before); + const overlay = element('body > .sd-loading'); + expectOverlayHit(button, overlay); + ref.close(); + expectRect(button, before); + expect(button.contains(hitCenter(button))).toBeTrue(); + } + }); + + it('keeps the drawer header button geometry with a document body overlay and restores close hit testing', async () => { + const drawer = await openDrawer(); + const button = element('[data-testid="drawer-action"]', drawer); + const close = element('.sd-side-drawer-close-btn', drawer); + const before = button.getBoundingClientRect(); + const ref = service.start(); + expectRect(button, before); + // A deliberate full-document loading overlay retains its existing interaction lock. + expectOverlayHit(close, element('body > .sd-loading')); + ref.close(); + expectRect(button, before); + const hit = hitCenter(close); + expect(close.contains(hit)).toBeTrue(); + hit.click(); + expect(fixture.componentInstance.closes).toBe(1); + }); + + it('keeps both header buttons in place and the drawer close clickable while content bodies are loading', async () => { + const pageBeforeDrawer = element('[data-testid="page-action"]').getBoundingClientRect(); + const drawer = await openDrawer(); + const pageButton = element('[data-testid="page-action"]'); + const drawerButton = element('[data-testid="drawer-action"]', drawer); + const pageRect = pageButton.getBoundingClientRect(); + const drawerRect = drawerButton.getBoundingClientRect(); + const pageRef = service.start('#loading-rendering-content'); + const drawerRef = service.start(`#${drawer.id} .sd-side-drawer-body`); + expectRect(pageButton, pageRect); + expectRect(drawerButton, drawerRect); + drawerRef.close(); + expectRect(drawerButton, drawerRect); + const reopened = service.start(`#${drawer.id} .sd-side-drawer-body`); + const close = element('.sd-side-drawer-close-btn', drawer); + const hit = hitCenter(close); + expect(close.contains(hit)).toBeTrue(); + hit.click(); + expect(fixture.componentInstance.closes).toBe(1); + expect(fixture.componentInstance.drawer().isOpened()).toBeFalse(); + reopened.close(); + pageRef.close(); + // Closing the drawer restores the document scrollbar, so compare with the pre-open viewport. + expectRect(pageButton, pageBeforeDrawer); + }); + + for (const type of ['fill', 'light', 'outline', 'text'] as const) { + it(`preserves the ${type} button spinner and capture-phase click blocking with overlay styles installed`, () => { + fixture.componentInstance.type = type; + fixture.detectChanges(); + const ref = service.start('#loading-rendering-content'); + const host = element('[data-testid="page-action"]'); + const button = element('button', host); + const spinner = element('mat-spinner', host); + expect(host.classList.contains('sd-loading')).toBeTrue(); + expect(host.hasAttribute('data-sd-loading-overlay')).toBeFalse(); + expect(spinner.getBoundingClientRect().width).toBe(18); + expect(spinner.getBoundingClientRect().height).toBe(18); + expect(getComputedStyle(spinner).visibility).toBe('visible'); + expect(getComputedStyle(button).pointerEvents).toBe('none'); + expect(button.contains(hitCenter(button))).toBeFalse(); + hitCenter(host).click(); + button.click(); // Also exercises activation that bypasses CSS pointer-events. + expect(fixture.componentInstance.clicks).toBe(0); + ref.close(); + button.click(); + expect(fixture.componentInstance.clicks).toBe(0); + fixture.componentInstance.loading.set(false); + fixture.detectChanges(); + expect(host.querySelector('mat-spinner')).toBeNull(); + expect(getComputedStyle(button).pointerEvents).toBe('auto'); + hitCenter(button).click(); + expect(fixture.componentInstance.clicks).toBe(1); + }); + } + + it('only styles owned overlay markup and keeps it rendered until the last ref and service owner release', () => { + const foreign = document.createElement('div'); + foreign.className = 'sd-loading'; + foreign.innerHTML = '
'; + document.body.appendChild(foreign); + foreignNodes.add(foreign); + const spinner = element('.sd-loading-spinner', foreign); + const foreignPosition = getComputedStyle(foreign).position; + const spinnerPosition = getComputedStyle(spinner).position; + const firstInjector = createEnvironmentInjector([SdLoadingService], TestBed.inject(EnvironmentInjector)); + const secondInjector = createEnvironmentInjector([SdLoadingService], TestBed.inject(EnvironmentInjector)); + injectors.add(firstInjector); + injectors.add(secondInjector); + const firstService = firstInjector.get(SdLoadingService); + const first = firstService.start(); + const sameOwner = firstService.start(); + const otherOwner = secondInjector.get(SdLoadingService).start(); + const overlay = element('body > .sd-loading[data-sd-loading-overlay]'); + const overlaySpinner = element('.sd-loading-spinner', overlay); + expect(getComputedStyle(foreign).position).toBe(foreignPosition); + expect(getComputedStyle(spinner).position).toBe(spinnerPosition); + expect(getComputedStyle(overlay).position).toBe('absolute'); + expect(getComputedStyle(overlaySpinner).animationName).toBe('sd-loading-spin'); + expect(overlaySpinner.getBoundingClientRect().width).toBeGreaterThan(0); + const rect = overlay.getBoundingClientRect(); + first.close(); + first.close(); + expect(sameOwner.closed).toBeFalse(); + expect(overlay.isConnected).toBeTrue(); + firstInjector.destroy(); + injectors.delete(firstInjector); + expect(sameOwner.closed).toBeTrue(); + expect(otherOwner.closed).toBeFalse(); + expect(element('body > .sd-loading[data-sd-loading-overlay]')).toBe(overlay); + expect(overlay.getBoundingClientRect().toJSON()).toEqual(rect.toJSON()); + expect(getComputedStyle(overlay).zIndex).toBe('99999'); + expect(getComputedStyle(overlaySpinner).animationName).toBe('sd-loading-spin'); + otherOwner.close(); + expect(overlay.isConnected).toBeFalse(); + expect(foreign.isConnected).toBeTrue(); + secondInjector.destroy(); + injectors.delete(secondInjector); + expect(document.querySelector('style[data-sd-loading-styles]')).toBeNull(); + }); +}); diff --git a/versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts b/versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts index 4385fb572..f5968bc32 100644 --- a/versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts +++ b/versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts @@ -320,6 +320,7 @@ describe('SdLoadingService', () => { expect(host.getAttribute('aria-busy')).toBe('true'); expect(overlay).toBeTruthy(); expect(overlay?.hasAttribute('id')).toBeFalse(); + expect(overlay?.hasAttribute('data-sd-loading-overlay')).toBeTrue(); expect(overlay?.getAttribute('role')).toBe('status'); expect(overlay?.getAttribute('aria-live')).toBe('polite'); expect(overlay?.querySelector(':scope > .sd-loading-spinner')).toBeTruthy(); @@ -425,7 +426,7 @@ describe('SdLoadingService', () => { ownedService.start(); expect(externalStyle.textContent).toContain(externalContent); - expect(externalStyle.textContent).toContain('.sd-loading {'); + expect(externalStyle.textContent).toContain('.sd-loading[data-sd-loading-overlay] {'); expect(externalStyle.textContent).toContain('.sd-loading-spinner'); expect(externalStyle.textContent).toContain('@keyframes sd-loading-spin'); diff --git a/versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.ts b/versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.ts index 0090f40ee..ae21f440d 100644 --- a/versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.ts +++ b/versions/v19/projects/sdcorejs-angular/services/loading/src/loading.service.ts @@ -40,8 +40,12 @@ interface SdLoadingStyleRecord { } const SD_LOADING_STYLE_ATTRIBUTE = 'data-sd-loading-styles'; +const SD_LOADING_OVERLAY_ATTRIBUTE = 'data-sd-loading-overlay'; +// why: SdButton cũng dùng .sd-loading; chỉ overlay do service tạo được nhận CSS phủ vùng. +const SD_LOADING_OVERLAY_SELECTOR = `.sd-loading[${SD_LOADING_OVERLAY_ATTRIBUTE}]`; +const SD_LOADING_SPINNER_SELECTOR = `${SD_LOADING_OVERLAY_SELECTOR} > .sd-loading-spinner`; const SD_LOADING_STYLES = ` -.sd-loading { +${SD_LOADING_OVERLAY_SELECTOR} { position: absolute; inset: 0; width: 100%; @@ -51,7 +55,7 @@ const SD_LOADING_STYLES = ` z-index: 99999; } -.sd-loading-spinner { +${SD_LOADING_SPINNER_SELECTOR} { position: absolute; top: calc(50% - 2.5rem); left: calc(50% - 2.5rem); @@ -290,6 +294,7 @@ export class SdLoadingService { const spinner = this.#document.createElement('div'); container.classList.add('sd-loading'); + container.setAttribute(SD_LOADING_OVERLAY_ATTRIBUTE, ''); container.setAttribute('role', 'status'); container.setAttribute('aria-live', 'polite'); container.setAttribute('aria-label', 'Loading'); @@ -346,8 +351,8 @@ export class SdLoadingService { #ensureRequiredStyleText(record: SdLoadingStyleRecord): void { const currentText = record.element.textContent ?? ''; const hasRequiredRules = - currentText.includes('.sd-loading {') && - currentText.includes('.sd-loading-spinner') && + currentText.includes(`${SD_LOADING_OVERLAY_SELECTOR} {`) && + currentText.includes(`${SD_LOADING_SPINNER_SELECTOR} {`) && currentText.includes('@keyframes sd-loading-spin'); if (hasRequiredRules) return; diff --git a/versions/v20/SYNC-STATUS.md b/versions/v20/SYNC-STATUS.md index 16cd310a6..04815c8ce 100644 --- a/versions/v20/SYNC-STATUS.md +++ b/versions/v20/SYNC-STATUS.md @@ -4,7 +4,7 @@ |-----|-------| | Angular Major | 20 | | Legacy Source Commit | d12478a1 | -| Updated At | 2026-09-10 17:28:25 | +| Updated At | 2026-09-10 21:30:26 | | Origin | repo-owned versions/v19 (final legacy sync vn-angular@d12478a1) | | Workspace Flow | versions/v19 → v20 | | Development Mode | repo-owned independent pack | diff --git a/versions/v20/projects/sdcorejs-angular/package.json b/versions/v20/projects/sdcorejs-angular/package.json index a61ff33d6..43a71f406 100644 --- a/versions/v20/projects/sdcorejs-angular/package.json +++ b/versions/v20/projects/sdcorejs-angular/package.json @@ -1,6 +1,6 @@ { "name": "@sdcorejs/angular", - "version": "20.2.7", + "version": "20.2.8", "description": "Angular component library: data table, query bar/builder, 20+ signal-based form controls, layout, auth and workflow services. Standalone, OnPush, signals-first. Published in parallel lines for Angular 19, 20, 21 and 22.", "keywords": [ "angular", diff --git a/versions/v20/projects/sdcorejs-angular/services/loading/sd-loading.md b/versions/v20/projects/sdcorejs-angular/services/loading/sd-loading.md index 267b93f85..bb6d22792 100644 --- a/versions/v20/projects/sdcorejs-angular/services/loading/sd-loading.md +++ b/versions/v20/projects/sdcorejs-angular/services/loading/sd-loading.md @@ -47,6 +47,8 @@ isLoading(selector = 'body'): Element | false | null; - Busy hosts receive `aria-busy="true"`; their previous value is restored after the final owner closes. - The overlay uses `role="status"`, `aria-live="polite"` and one spinner hidden from assistive technology. +- Service-created overlays carry `.sd-loading[data-sd-loading-overlay]`. Overlay layout rules target this ownership marker; spinner rules target only its direct `.sd-loading-spinner` child. The existing class names remain available. `SdButton` keeps its separate `.sd-loading` state, Material spinner and click blocking without receiving overlay positioning or sizing. +- Choose a positioned content host (for example a drawer's `.sd-side-drawer-body`) when header actions must remain interactive. The default `start()` covers the whole document body and intentionally blocks interactions beneath that overlay. - One shared `style[data-sd-loading-styles]` is maintained per document. The service adopts an existing consumer style element without deleting consumer-owned content. - Removing/reparenting an overlay is repaired on the next acquisition. - Injector teardown closes all owned refs, removes library-owned overlays/style content and clears bookkeeping. @@ -71,4 +73,4 @@ Existing balanced `start()` / `stop()` calls remain valid. For concurrency safet } ``` -Focused coverage lives in `loading.service.spec.ts`. +Focused lifecycle coverage lives in `loading.service.spec.ts`. `loading-rendering.spec.ts` runs real Chrome rendering through Karma: page/drawer header geometry, spinner and click behavior, close-button hit testing, foreign class isolation and overlapping service/ref cleanup. diff --git a/versions/v20/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts b/versions/v20/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts new file mode 100644 index 000000000..d5163242e --- /dev/null +++ b/versions/v20/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts @@ -0,0 +1,257 @@ +import { Component, EnvironmentInjector, createEnvironmentInjector, signal, viewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SdButton, SdButtonType } from '@sdcorejs/angular/components/button'; +import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer'; +import { SdPageComponent } from '../../../modules/layout/components/page/page.component'; +import { SdLoadingService } from './loading.service'; + +@Component({ + imports: [SdButton, SdSideDrawer, SdPageComponent], + template: ` + + +
Page content
+
+ + + Drawer content + + `, + styles: ` + :host { + display: block; + position: fixed; + inset: 32px; + } + :host ::ng-deep .c-page-header > div { + display: flex; + justify-content: space-between; + padding: 16px; + } + #loading-rendering-content { + position: relative; + height: 200px; + } + `, +}) +class LoadingRenderingHost { + readonly drawer = viewChild.required(SdSideDrawer); + readonly loading = signal(true); + type: SdButtonType = 'light'; + clicks = 0; + closes = 0; +} + +// why: Karma renders real component CSS in Chrome. DOM/class-only assertions cannot detect +// a global service selector changing the button's containing block or intercepting a drawer close. +describe('SdLoadingService and SdButton browser rendering', () => { + let fixture: ComponentFixture; + let service: SdLoadingService; + let previousScroll: { left: number; top: number }; + const injectors = new Set(); + const foreignNodes = new Set(); + + beforeEach(async () => { + previousScroll = { left: window.scrollX, top: window.scrollY }; + await TestBed.configureTestingModule({ imports: [LoadingRenderingHost, NoopAnimationsModule] }).compileComponents(); + fixture = TestBed.createComponent(LoadingRenderingHost); + service = TestBed.inject(SdLoadingService); + fixture.detectChanges(); + await fixture.whenStable(); + // why: các test focus/scroll khác dùng chung document của Karma; overlay absolute cần + // viewport ban đầu xác định để hit-test không phụ thuộc vị trí cuộn của test chạy trước. + window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); + }); + + afterEach(() => { + for (const injector of injectors) injector.destroy(); + injectors.clear(); + TestBed.resetTestingModule(); + for (const node of foreignNodes) node.remove(); + foreignNodes.clear(); + window.scrollTo({ ...previousScroll, behavior: 'instant' }); + }); + + function element(selector: string, root: ParentNode = document): HTMLElement { + const result = root.querySelector(selector); + if (!result) throw new Error(`Missing rendered element: ${selector}`); + return result; + } + + function expectRect(target: HTMLElement, before: DOMRect): void { + const after = target.getBoundingClientRect(); + for (const key of ['x', 'y', 'width', 'height'] as const) { + expect(after[key]) + .withContext(`${target.dataset['testid'] ?? target.className}: ${key}`) + .toBeCloseTo(before[key], 1); + } + expect(after.width).toBeGreaterThan(0); + expect(after.height).toBeGreaterThan(0); + expect(getComputedStyle(target).position).not.toBe('absolute'); + expect(getComputedStyle(target).zIndex).not.toBe('99999'); + } + + function hitCenter(target: HTMLElement): HTMLElement { + const rect = target.getBoundingClientRect(); + const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + if (!(hit instanceof HTMLElement)) throw new Error('No hit target at rendered center'); + return hit; + } + + function expectOverlayHit(target: HTMLElement, overlay: HTMLElement): void { + expect(hitCenter(target)) + .withContext( + JSON.stringify({ + overlay: overlay.getBoundingClientRect().toJSON(), + target: target.getBoundingClientRect().toJSON(), + scroll: [window.scrollX, window.scrollY], + position: getComputedStyle(document.body).position, + pointerEvents: getComputedStyle(overlay).pointerEvents, + }) + ) + .toBe(overlay); + } + + async function openDrawer(): Promise { + fixture.componentInstance.drawer().open(); + fixture.detectChanges(); + await fixture.whenStable(); + const drawer = element(`#${fixture.componentInstance.drawer().id}`); + // Finish the real CSS entrance transition before measuring, without timers or mocked layout. + for (const animation of drawer.getAnimations()) animation.finish(); + return drawer; + } + + it('keeps the page header button geometry across repeated document body overlay starts and stops', () => { + const button = element('[data-testid="page-action"]'); + const before = button.getBoundingClientRect(); + for (let cycle = 0; cycle < 2; cycle++) { + const ref = service.start(); + expectRect(button, before); + const overlay = element('body > .sd-loading'); + expectOverlayHit(button, overlay); + ref.close(); + expectRect(button, before); + expect(button.contains(hitCenter(button))).toBeTrue(); + } + }); + + it('keeps the drawer header button geometry with a document body overlay and restores close hit testing', async () => { + const drawer = await openDrawer(); + const button = element('[data-testid="drawer-action"]', drawer); + const close = element('.sd-side-drawer-close-btn', drawer); + const before = button.getBoundingClientRect(); + const ref = service.start(); + expectRect(button, before); + // A deliberate full-document loading overlay retains its existing interaction lock. + expectOverlayHit(close, element('body > .sd-loading')); + ref.close(); + expectRect(button, before); + const hit = hitCenter(close); + expect(close.contains(hit)).toBeTrue(); + hit.click(); + expect(fixture.componentInstance.closes).toBe(1); + }); + + it('keeps both header buttons in place and the drawer close clickable while content bodies are loading', async () => { + const pageBeforeDrawer = element('[data-testid="page-action"]').getBoundingClientRect(); + const drawer = await openDrawer(); + const pageButton = element('[data-testid="page-action"]'); + const drawerButton = element('[data-testid="drawer-action"]', drawer); + const pageRect = pageButton.getBoundingClientRect(); + const drawerRect = drawerButton.getBoundingClientRect(); + const pageRef = service.start('#loading-rendering-content'); + const drawerRef = service.start(`#${drawer.id} .sd-side-drawer-body`); + expectRect(pageButton, pageRect); + expectRect(drawerButton, drawerRect); + drawerRef.close(); + expectRect(drawerButton, drawerRect); + const reopened = service.start(`#${drawer.id} .sd-side-drawer-body`); + const close = element('.sd-side-drawer-close-btn', drawer); + const hit = hitCenter(close); + expect(close.contains(hit)).toBeTrue(); + hit.click(); + expect(fixture.componentInstance.closes).toBe(1); + expect(fixture.componentInstance.drawer().isOpened()).toBeFalse(); + reopened.close(); + pageRef.close(); + // Closing the drawer restores the document scrollbar, so compare with the pre-open viewport. + expectRect(pageButton, pageBeforeDrawer); + }); + + for (const type of ['fill', 'light', 'outline', 'text'] as const) { + it(`preserves the ${type} button spinner and capture-phase click blocking with overlay styles installed`, () => { + fixture.componentInstance.type = type; + fixture.detectChanges(); + const ref = service.start('#loading-rendering-content'); + const host = element('[data-testid="page-action"]'); + const button = element('button', host); + const spinner = element('mat-spinner', host); + expect(host.classList.contains('sd-loading')).toBeTrue(); + expect(host.hasAttribute('data-sd-loading-overlay')).toBeFalse(); + expect(spinner.getBoundingClientRect().width).toBe(18); + expect(spinner.getBoundingClientRect().height).toBe(18); + expect(getComputedStyle(spinner).visibility).toBe('visible'); + expect(getComputedStyle(button).pointerEvents).toBe('none'); + expect(button.contains(hitCenter(button))).toBeFalse(); + hitCenter(host).click(); + button.click(); // Also exercises activation that bypasses CSS pointer-events. + expect(fixture.componentInstance.clicks).toBe(0); + ref.close(); + button.click(); + expect(fixture.componentInstance.clicks).toBe(0); + fixture.componentInstance.loading.set(false); + fixture.detectChanges(); + expect(host.querySelector('mat-spinner')).toBeNull(); + expect(getComputedStyle(button).pointerEvents).toBe('auto'); + hitCenter(button).click(); + expect(fixture.componentInstance.clicks).toBe(1); + }); + } + + it('only styles owned overlay markup and keeps it rendered until the last ref and service owner release', () => { + const foreign = document.createElement('div'); + foreign.className = 'sd-loading'; + foreign.innerHTML = '
'; + document.body.appendChild(foreign); + foreignNodes.add(foreign); + const spinner = element('.sd-loading-spinner', foreign); + const foreignPosition = getComputedStyle(foreign).position; + const spinnerPosition = getComputedStyle(spinner).position; + const firstInjector = createEnvironmentInjector([SdLoadingService], TestBed.inject(EnvironmentInjector)); + const secondInjector = createEnvironmentInjector([SdLoadingService], TestBed.inject(EnvironmentInjector)); + injectors.add(firstInjector); + injectors.add(secondInjector); + const firstService = firstInjector.get(SdLoadingService); + const first = firstService.start(); + const sameOwner = firstService.start(); + const otherOwner = secondInjector.get(SdLoadingService).start(); + const overlay = element('body > .sd-loading[data-sd-loading-overlay]'); + const overlaySpinner = element('.sd-loading-spinner', overlay); + expect(getComputedStyle(foreign).position).toBe(foreignPosition); + expect(getComputedStyle(spinner).position).toBe(spinnerPosition); + expect(getComputedStyle(overlay).position).toBe('absolute'); + expect(getComputedStyle(overlaySpinner).animationName).toBe('sd-loading-spin'); + expect(overlaySpinner.getBoundingClientRect().width).toBeGreaterThan(0); + const rect = overlay.getBoundingClientRect(); + first.close(); + first.close(); + expect(sameOwner.closed).toBeFalse(); + expect(overlay.isConnected).toBeTrue(); + firstInjector.destroy(); + injectors.delete(firstInjector); + expect(sameOwner.closed).toBeTrue(); + expect(otherOwner.closed).toBeFalse(); + expect(element('body > .sd-loading[data-sd-loading-overlay]')).toBe(overlay); + expect(overlay.getBoundingClientRect().toJSON()).toEqual(rect.toJSON()); + expect(getComputedStyle(overlay).zIndex).toBe('99999'); + expect(getComputedStyle(overlaySpinner).animationName).toBe('sd-loading-spin'); + otherOwner.close(); + expect(overlay.isConnected).toBeFalse(); + expect(foreign.isConnected).toBeTrue(); + secondInjector.destroy(); + injectors.delete(secondInjector); + expect(document.querySelector('style[data-sd-loading-styles]')).toBeNull(); + }); +}); diff --git a/versions/v20/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts b/versions/v20/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts index 4385fb572..f5968bc32 100644 --- a/versions/v20/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts +++ b/versions/v20/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts @@ -320,6 +320,7 @@ describe('SdLoadingService', () => { expect(host.getAttribute('aria-busy')).toBe('true'); expect(overlay).toBeTruthy(); expect(overlay?.hasAttribute('id')).toBeFalse(); + expect(overlay?.hasAttribute('data-sd-loading-overlay')).toBeTrue(); expect(overlay?.getAttribute('role')).toBe('status'); expect(overlay?.getAttribute('aria-live')).toBe('polite'); expect(overlay?.querySelector(':scope > .sd-loading-spinner')).toBeTruthy(); @@ -425,7 +426,7 @@ describe('SdLoadingService', () => { ownedService.start(); expect(externalStyle.textContent).toContain(externalContent); - expect(externalStyle.textContent).toContain('.sd-loading {'); + expect(externalStyle.textContent).toContain('.sd-loading[data-sd-loading-overlay] {'); expect(externalStyle.textContent).toContain('.sd-loading-spinner'); expect(externalStyle.textContent).toContain('@keyframes sd-loading-spin'); diff --git a/versions/v20/projects/sdcorejs-angular/services/loading/src/loading.service.ts b/versions/v20/projects/sdcorejs-angular/services/loading/src/loading.service.ts index 0090f40ee..ae21f440d 100644 --- a/versions/v20/projects/sdcorejs-angular/services/loading/src/loading.service.ts +++ b/versions/v20/projects/sdcorejs-angular/services/loading/src/loading.service.ts @@ -40,8 +40,12 @@ interface SdLoadingStyleRecord { } const SD_LOADING_STYLE_ATTRIBUTE = 'data-sd-loading-styles'; +const SD_LOADING_OVERLAY_ATTRIBUTE = 'data-sd-loading-overlay'; +// why: SdButton cũng dùng .sd-loading; chỉ overlay do service tạo được nhận CSS phủ vùng. +const SD_LOADING_OVERLAY_SELECTOR = `.sd-loading[${SD_LOADING_OVERLAY_ATTRIBUTE}]`; +const SD_LOADING_SPINNER_SELECTOR = `${SD_LOADING_OVERLAY_SELECTOR} > .sd-loading-spinner`; const SD_LOADING_STYLES = ` -.sd-loading { +${SD_LOADING_OVERLAY_SELECTOR} { position: absolute; inset: 0; width: 100%; @@ -51,7 +55,7 @@ const SD_LOADING_STYLES = ` z-index: 99999; } -.sd-loading-spinner { +${SD_LOADING_SPINNER_SELECTOR} { position: absolute; top: calc(50% - 2.5rem); left: calc(50% - 2.5rem); @@ -290,6 +294,7 @@ export class SdLoadingService { const spinner = this.#document.createElement('div'); container.classList.add('sd-loading'); + container.setAttribute(SD_LOADING_OVERLAY_ATTRIBUTE, ''); container.setAttribute('role', 'status'); container.setAttribute('aria-live', 'polite'); container.setAttribute('aria-label', 'Loading'); @@ -346,8 +351,8 @@ export class SdLoadingService { #ensureRequiredStyleText(record: SdLoadingStyleRecord): void { const currentText = record.element.textContent ?? ''; const hasRequiredRules = - currentText.includes('.sd-loading {') && - currentText.includes('.sd-loading-spinner') && + currentText.includes(`${SD_LOADING_OVERLAY_SELECTOR} {`) && + currentText.includes(`${SD_LOADING_SPINNER_SELECTOR} {`) && currentText.includes('@keyframes sd-loading-spin'); if (hasRequiredRules) return; diff --git a/versions/v21/SYNC-STATUS.md b/versions/v21/SYNC-STATUS.md index b5b2a10d2..2bf4d6aa1 100644 --- a/versions/v21/SYNC-STATUS.md +++ b/versions/v21/SYNC-STATUS.md @@ -4,7 +4,7 @@ |-----|-------| | Angular Major | 21 | | Legacy Source Commit | d12478a1 | -| Updated At | 2026-09-10 17:28:25 | +| Updated At | 2026-09-10 21:30:26 | | Origin | repo-owned versions/v19 (final legacy sync vn-angular@d12478a1) | | Workspace Flow | versions/v19 → v21 | | Development Mode | repo-owned independent pack | diff --git a/versions/v21/projects/sdcorejs-angular/package.json b/versions/v21/projects/sdcorejs-angular/package.json index 3b337dd54..27c09a3b1 100644 --- a/versions/v21/projects/sdcorejs-angular/package.json +++ b/versions/v21/projects/sdcorejs-angular/package.json @@ -1,6 +1,6 @@ { "name": "@sdcorejs/angular", - "version": "21.2.7", + "version": "21.2.8", "description": "Angular component library: data table, query bar/builder, 20+ signal-based form controls, layout, auth and workflow services. Standalone, OnPush, signals-first. Published in parallel lines for Angular 19, 20, 21 and 22.", "keywords": [ "angular", diff --git a/versions/v21/projects/sdcorejs-angular/services/loading/sd-loading.md b/versions/v21/projects/sdcorejs-angular/services/loading/sd-loading.md index 267b93f85..bb6d22792 100644 --- a/versions/v21/projects/sdcorejs-angular/services/loading/sd-loading.md +++ b/versions/v21/projects/sdcorejs-angular/services/loading/sd-loading.md @@ -47,6 +47,8 @@ isLoading(selector = 'body'): Element | false | null; - Busy hosts receive `aria-busy="true"`; their previous value is restored after the final owner closes. - The overlay uses `role="status"`, `aria-live="polite"` and one spinner hidden from assistive technology. +- Service-created overlays carry `.sd-loading[data-sd-loading-overlay]`. Overlay layout rules target this ownership marker; spinner rules target only its direct `.sd-loading-spinner` child. The existing class names remain available. `SdButton` keeps its separate `.sd-loading` state, Material spinner and click blocking without receiving overlay positioning or sizing. +- Choose a positioned content host (for example a drawer's `.sd-side-drawer-body`) when header actions must remain interactive. The default `start()` covers the whole document body and intentionally blocks interactions beneath that overlay. - One shared `style[data-sd-loading-styles]` is maintained per document. The service adopts an existing consumer style element without deleting consumer-owned content. - Removing/reparenting an overlay is repaired on the next acquisition. - Injector teardown closes all owned refs, removes library-owned overlays/style content and clears bookkeeping. @@ -71,4 +73,4 @@ Existing balanced `start()` / `stop()` calls remain valid. For concurrency safet } ``` -Focused coverage lives in `loading.service.spec.ts`. +Focused lifecycle coverage lives in `loading.service.spec.ts`. `loading-rendering.spec.ts` runs real Chrome rendering through Karma: page/drawer header geometry, spinner and click behavior, close-button hit testing, foreign class isolation and overlapping service/ref cleanup. diff --git a/versions/v21/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts b/versions/v21/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts new file mode 100644 index 000000000..d5163242e --- /dev/null +++ b/versions/v21/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts @@ -0,0 +1,257 @@ +import { Component, EnvironmentInjector, createEnvironmentInjector, signal, viewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SdButton, SdButtonType } from '@sdcorejs/angular/components/button'; +import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer'; +import { SdPageComponent } from '../../../modules/layout/components/page/page.component'; +import { SdLoadingService } from './loading.service'; + +@Component({ + imports: [SdButton, SdSideDrawer, SdPageComponent], + template: ` + + +
Page content
+
+ + + Drawer content + + `, + styles: ` + :host { + display: block; + position: fixed; + inset: 32px; + } + :host ::ng-deep .c-page-header > div { + display: flex; + justify-content: space-between; + padding: 16px; + } + #loading-rendering-content { + position: relative; + height: 200px; + } + `, +}) +class LoadingRenderingHost { + readonly drawer = viewChild.required(SdSideDrawer); + readonly loading = signal(true); + type: SdButtonType = 'light'; + clicks = 0; + closes = 0; +} + +// why: Karma renders real component CSS in Chrome. DOM/class-only assertions cannot detect +// a global service selector changing the button's containing block or intercepting a drawer close. +describe('SdLoadingService and SdButton browser rendering', () => { + let fixture: ComponentFixture; + let service: SdLoadingService; + let previousScroll: { left: number; top: number }; + const injectors = new Set(); + const foreignNodes = new Set(); + + beforeEach(async () => { + previousScroll = { left: window.scrollX, top: window.scrollY }; + await TestBed.configureTestingModule({ imports: [LoadingRenderingHost, NoopAnimationsModule] }).compileComponents(); + fixture = TestBed.createComponent(LoadingRenderingHost); + service = TestBed.inject(SdLoadingService); + fixture.detectChanges(); + await fixture.whenStable(); + // why: các test focus/scroll khác dùng chung document của Karma; overlay absolute cần + // viewport ban đầu xác định để hit-test không phụ thuộc vị trí cuộn của test chạy trước. + window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); + }); + + afterEach(() => { + for (const injector of injectors) injector.destroy(); + injectors.clear(); + TestBed.resetTestingModule(); + for (const node of foreignNodes) node.remove(); + foreignNodes.clear(); + window.scrollTo({ ...previousScroll, behavior: 'instant' }); + }); + + function element(selector: string, root: ParentNode = document): HTMLElement { + const result = root.querySelector(selector); + if (!result) throw new Error(`Missing rendered element: ${selector}`); + return result; + } + + function expectRect(target: HTMLElement, before: DOMRect): void { + const after = target.getBoundingClientRect(); + for (const key of ['x', 'y', 'width', 'height'] as const) { + expect(after[key]) + .withContext(`${target.dataset['testid'] ?? target.className}: ${key}`) + .toBeCloseTo(before[key], 1); + } + expect(after.width).toBeGreaterThan(0); + expect(after.height).toBeGreaterThan(0); + expect(getComputedStyle(target).position).not.toBe('absolute'); + expect(getComputedStyle(target).zIndex).not.toBe('99999'); + } + + function hitCenter(target: HTMLElement): HTMLElement { + const rect = target.getBoundingClientRect(); + const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + if (!(hit instanceof HTMLElement)) throw new Error('No hit target at rendered center'); + return hit; + } + + function expectOverlayHit(target: HTMLElement, overlay: HTMLElement): void { + expect(hitCenter(target)) + .withContext( + JSON.stringify({ + overlay: overlay.getBoundingClientRect().toJSON(), + target: target.getBoundingClientRect().toJSON(), + scroll: [window.scrollX, window.scrollY], + position: getComputedStyle(document.body).position, + pointerEvents: getComputedStyle(overlay).pointerEvents, + }) + ) + .toBe(overlay); + } + + async function openDrawer(): Promise { + fixture.componentInstance.drawer().open(); + fixture.detectChanges(); + await fixture.whenStable(); + const drawer = element(`#${fixture.componentInstance.drawer().id}`); + // Finish the real CSS entrance transition before measuring, without timers or mocked layout. + for (const animation of drawer.getAnimations()) animation.finish(); + return drawer; + } + + it('keeps the page header button geometry across repeated document body overlay starts and stops', () => { + const button = element('[data-testid="page-action"]'); + const before = button.getBoundingClientRect(); + for (let cycle = 0; cycle < 2; cycle++) { + const ref = service.start(); + expectRect(button, before); + const overlay = element('body > .sd-loading'); + expectOverlayHit(button, overlay); + ref.close(); + expectRect(button, before); + expect(button.contains(hitCenter(button))).toBeTrue(); + } + }); + + it('keeps the drawer header button geometry with a document body overlay and restores close hit testing', async () => { + const drawer = await openDrawer(); + const button = element('[data-testid="drawer-action"]', drawer); + const close = element('.sd-side-drawer-close-btn', drawer); + const before = button.getBoundingClientRect(); + const ref = service.start(); + expectRect(button, before); + // A deliberate full-document loading overlay retains its existing interaction lock. + expectOverlayHit(close, element('body > .sd-loading')); + ref.close(); + expectRect(button, before); + const hit = hitCenter(close); + expect(close.contains(hit)).toBeTrue(); + hit.click(); + expect(fixture.componentInstance.closes).toBe(1); + }); + + it('keeps both header buttons in place and the drawer close clickable while content bodies are loading', async () => { + const pageBeforeDrawer = element('[data-testid="page-action"]').getBoundingClientRect(); + const drawer = await openDrawer(); + const pageButton = element('[data-testid="page-action"]'); + const drawerButton = element('[data-testid="drawer-action"]', drawer); + const pageRect = pageButton.getBoundingClientRect(); + const drawerRect = drawerButton.getBoundingClientRect(); + const pageRef = service.start('#loading-rendering-content'); + const drawerRef = service.start(`#${drawer.id} .sd-side-drawer-body`); + expectRect(pageButton, pageRect); + expectRect(drawerButton, drawerRect); + drawerRef.close(); + expectRect(drawerButton, drawerRect); + const reopened = service.start(`#${drawer.id} .sd-side-drawer-body`); + const close = element('.sd-side-drawer-close-btn', drawer); + const hit = hitCenter(close); + expect(close.contains(hit)).toBeTrue(); + hit.click(); + expect(fixture.componentInstance.closes).toBe(1); + expect(fixture.componentInstance.drawer().isOpened()).toBeFalse(); + reopened.close(); + pageRef.close(); + // Closing the drawer restores the document scrollbar, so compare with the pre-open viewport. + expectRect(pageButton, pageBeforeDrawer); + }); + + for (const type of ['fill', 'light', 'outline', 'text'] as const) { + it(`preserves the ${type} button spinner and capture-phase click blocking with overlay styles installed`, () => { + fixture.componentInstance.type = type; + fixture.detectChanges(); + const ref = service.start('#loading-rendering-content'); + const host = element('[data-testid="page-action"]'); + const button = element('button', host); + const spinner = element('mat-spinner', host); + expect(host.classList.contains('sd-loading')).toBeTrue(); + expect(host.hasAttribute('data-sd-loading-overlay')).toBeFalse(); + expect(spinner.getBoundingClientRect().width).toBe(18); + expect(spinner.getBoundingClientRect().height).toBe(18); + expect(getComputedStyle(spinner).visibility).toBe('visible'); + expect(getComputedStyle(button).pointerEvents).toBe('none'); + expect(button.contains(hitCenter(button))).toBeFalse(); + hitCenter(host).click(); + button.click(); // Also exercises activation that bypasses CSS pointer-events. + expect(fixture.componentInstance.clicks).toBe(0); + ref.close(); + button.click(); + expect(fixture.componentInstance.clicks).toBe(0); + fixture.componentInstance.loading.set(false); + fixture.detectChanges(); + expect(host.querySelector('mat-spinner')).toBeNull(); + expect(getComputedStyle(button).pointerEvents).toBe('auto'); + hitCenter(button).click(); + expect(fixture.componentInstance.clicks).toBe(1); + }); + } + + it('only styles owned overlay markup and keeps it rendered until the last ref and service owner release', () => { + const foreign = document.createElement('div'); + foreign.className = 'sd-loading'; + foreign.innerHTML = '
'; + document.body.appendChild(foreign); + foreignNodes.add(foreign); + const spinner = element('.sd-loading-spinner', foreign); + const foreignPosition = getComputedStyle(foreign).position; + const spinnerPosition = getComputedStyle(spinner).position; + const firstInjector = createEnvironmentInjector([SdLoadingService], TestBed.inject(EnvironmentInjector)); + const secondInjector = createEnvironmentInjector([SdLoadingService], TestBed.inject(EnvironmentInjector)); + injectors.add(firstInjector); + injectors.add(secondInjector); + const firstService = firstInjector.get(SdLoadingService); + const first = firstService.start(); + const sameOwner = firstService.start(); + const otherOwner = secondInjector.get(SdLoadingService).start(); + const overlay = element('body > .sd-loading[data-sd-loading-overlay]'); + const overlaySpinner = element('.sd-loading-spinner', overlay); + expect(getComputedStyle(foreign).position).toBe(foreignPosition); + expect(getComputedStyle(spinner).position).toBe(spinnerPosition); + expect(getComputedStyle(overlay).position).toBe('absolute'); + expect(getComputedStyle(overlaySpinner).animationName).toBe('sd-loading-spin'); + expect(overlaySpinner.getBoundingClientRect().width).toBeGreaterThan(0); + const rect = overlay.getBoundingClientRect(); + first.close(); + first.close(); + expect(sameOwner.closed).toBeFalse(); + expect(overlay.isConnected).toBeTrue(); + firstInjector.destroy(); + injectors.delete(firstInjector); + expect(sameOwner.closed).toBeTrue(); + expect(otherOwner.closed).toBeFalse(); + expect(element('body > .sd-loading[data-sd-loading-overlay]')).toBe(overlay); + expect(overlay.getBoundingClientRect().toJSON()).toEqual(rect.toJSON()); + expect(getComputedStyle(overlay).zIndex).toBe('99999'); + expect(getComputedStyle(overlaySpinner).animationName).toBe('sd-loading-spin'); + otherOwner.close(); + expect(overlay.isConnected).toBeFalse(); + expect(foreign.isConnected).toBeTrue(); + secondInjector.destroy(); + injectors.delete(secondInjector); + expect(document.querySelector('style[data-sd-loading-styles]')).toBeNull(); + }); +}); diff --git a/versions/v21/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts b/versions/v21/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts index 4385fb572..f5968bc32 100644 --- a/versions/v21/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts +++ b/versions/v21/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts @@ -320,6 +320,7 @@ describe('SdLoadingService', () => { expect(host.getAttribute('aria-busy')).toBe('true'); expect(overlay).toBeTruthy(); expect(overlay?.hasAttribute('id')).toBeFalse(); + expect(overlay?.hasAttribute('data-sd-loading-overlay')).toBeTrue(); expect(overlay?.getAttribute('role')).toBe('status'); expect(overlay?.getAttribute('aria-live')).toBe('polite'); expect(overlay?.querySelector(':scope > .sd-loading-spinner')).toBeTruthy(); @@ -425,7 +426,7 @@ describe('SdLoadingService', () => { ownedService.start(); expect(externalStyle.textContent).toContain(externalContent); - expect(externalStyle.textContent).toContain('.sd-loading {'); + expect(externalStyle.textContent).toContain('.sd-loading[data-sd-loading-overlay] {'); expect(externalStyle.textContent).toContain('.sd-loading-spinner'); expect(externalStyle.textContent).toContain('@keyframes sd-loading-spin'); diff --git a/versions/v21/projects/sdcorejs-angular/services/loading/src/loading.service.ts b/versions/v21/projects/sdcorejs-angular/services/loading/src/loading.service.ts index 0090f40ee..ae21f440d 100644 --- a/versions/v21/projects/sdcorejs-angular/services/loading/src/loading.service.ts +++ b/versions/v21/projects/sdcorejs-angular/services/loading/src/loading.service.ts @@ -40,8 +40,12 @@ interface SdLoadingStyleRecord { } const SD_LOADING_STYLE_ATTRIBUTE = 'data-sd-loading-styles'; +const SD_LOADING_OVERLAY_ATTRIBUTE = 'data-sd-loading-overlay'; +// why: SdButton cũng dùng .sd-loading; chỉ overlay do service tạo được nhận CSS phủ vùng. +const SD_LOADING_OVERLAY_SELECTOR = `.sd-loading[${SD_LOADING_OVERLAY_ATTRIBUTE}]`; +const SD_LOADING_SPINNER_SELECTOR = `${SD_LOADING_OVERLAY_SELECTOR} > .sd-loading-spinner`; const SD_LOADING_STYLES = ` -.sd-loading { +${SD_LOADING_OVERLAY_SELECTOR} { position: absolute; inset: 0; width: 100%; @@ -51,7 +55,7 @@ const SD_LOADING_STYLES = ` z-index: 99999; } -.sd-loading-spinner { +${SD_LOADING_SPINNER_SELECTOR} { position: absolute; top: calc(50% - 2.5rem); left: calc(50% - 2.5rem); @@ -290,6 +294,7 @@ export class SdLoadingService { const spinner = this.#document.createElement('div'); container.classList.add('sd-loading'); + container.setAttribute(SD_LOADING_OVERLAY_ATTRIBUTE, ''); container.setAttribute('role', 'status'); container.setAttribute('aria-live', 'polite'); container.setAttribute('aria-label', 'Loading'); @@ -346,8 +351,8 @@ export class SdLoadingService { #ensureRequiredStyleText(record: SdLoadingStyleRecord): void { const currentText = record.element.textContent ?? ''; const hasRequiredRules = - currentText.includes('.sd-loading {') && - currentText.includes('.sd-loading-spinner') && + currentText.includes(`${SD_LOADING_OVERLAY_SELECTOR} {`) && + currentText.includes(`${SD_LOADING_SPINNER_SELECTOR} {`) && currentText.includes('@keyframes sd-loading-spin'); if (hasRequiredRules) return; diff --git a/versions/v22/SYNC-STATUS.md b/versions/v22/SYNC-STATUS.md index 0e9502d1f..d380dc745 100644 --- a/versions/v22/SYNC-STATUS.md +++ b/versions/v22/SYNC-STATUS.md @@ -4,7 +4,7 @@ |-----|-------| | Angular Major | 22 | | Legacy Source Commit | d12478a1 | -| Updated At | 2026-09-10 17:28:25 | +| Updated At | 2026-09-10 21:30:26 | | Origin | repo-owned versions/v19 (final legacy sync vn-angular@d12478a1) | | Workspace Flow | versions/v19 → v22 | | Development Mode | repo-owned independent pack | diff --git a/versions/v22/projects/sdcorejs-angular/package.json b/versions/v22/projects/sdcorejs-angular/package.json index 33c2f8a12..a4d392633 100644 --- a/versions/v22/projects/sdcorejs-angular/package.json +++ b/versions/v22/projects/sdcorejs-angular/package.json @@ -1,6 +1,6 @@ { "name": "@sdcorejs/angular", - "version": "22.2.7", + "version": "22.2.8", "description": "Angular component library: data table, query bar/builder, 20+ signal-based form controls, layout, auth and workflow services. Standalone, OnPush, signals-first. Published in parallel lines for Angular 19, 20, 21 and 22.", "keywords": [ "angular", diff --git a/versions/v22/projects/sdcorejs-angular/services/loading/sd-loading.md b/versions/v22/projects/sdcorejs-angular/services/loading/sd-loading.md index 267b93f85..bb6d22792 100644 --- a/versions/v22/projects/sdcorejs-angular/services/loading/sd-loading.md +++ b/versions/v22/projects/sdcorejs-angular/services/loading/sd-loading.md @@ -47,6 +47,8 @@ isLoading(selector = 'body'): Element | false | null; - Busy hosts receive `aria-busy="true"`; their previous value is restored after the final owner closes. - The overlay uses `role="status"`, `aria-live="polite"` and one spinner hidden from assistive technology. +- Service-created overlays carry `.sd-loading[data-sd-loading-overlay]`. Overlay layout rules target this ownership marker; spinner rules target only its direct `.sd-loading-spinner` child. The existing class names remain available. `SdButton` keeps its separate `.sd-loading` state, Material spinner and click blocking without receiving overlay positioning or sizing. +- Choose a positioned content host (for example a drawer's `.sd-side-drawer-body`) when header actions must remain interactive. The default `start()` covers the whole document body and intentionally blocks interactions beneath that overlay. - One shared `style[data-sd-loading-styles]` is maintained per document. The service adopts an existing consumer style element without deleting consumer-owned content. - Removing/reparenting an overlay is repaired on the next acquisition. - Injector teardown closes all owned refs, removes library-owned overlays/style content and clears bookkeeping. @@ -71,4 +73,4 @@ Existing balanced `start()` / `stop()` calls remain valid. For concurrency safet } ``` -Focused coverage lives in `loading.service.spec.ts`. +Focused lifecycle coverage lives in `loading.service.spec.ts`. `loading-rendering.spec.ts` runs real Chrome rendering through Karma: page/drawer header geometry, spinner and click behavior, close-button hit testing, foreign class isolation and overlapping service/ref cleanup. diff --git a/versions/v22/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts b/versions/v22/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts new file mode 100644 index 000000000..46401e35f --- /dev/null +++ b/versions/v22/projects/sdcorejs-angular/services/loading/src/loading-rendering.spec.ts @@ -0,0 +1,259 @@ +import { ChangeDetectionStrategy as SdAngular22ChangeDetectionStrategy } from '@angular/core'; +import { Component, EnvironmentInjector, createEnvironmentInjector, signal, viewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SdButton, SdButtonType } from '@sdcorejs/angular/components/button'; +import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer'; +import { SdPageComponent } from '../../../modules/layout/components/page/page.component'; +import { SdLoadingService } from './loading.service'; + +@Component({ + changeDetection: SdAngular22ChangeDetectionStrategy.Eager, + imports: [SdButton, SdSideDrawer, SdPageComponent], + template: ` + + +
Page content
+
+ + + Drawer content + + `, + styles: ` + :host { + display: block; + position: fixed; + inset: 32px; + } + :host ::ng-deep .c-page-header > div { + display: flex; + justify-content: space-between; + padding: 16px; + } + #loading-rendering-content { + position: relative; + height: 200px; + } + `, +}) +class LoadingRenderingHost { + readonly drawer = viewChild.required(SdSideDrawer); + readonly loading = signal(true); + type: SdButtonType = 'light'; + clicks = 0; + closes = 0; +} + +// why: Karma renders real component CSS in Chrome. DOM/class-only assertions cannot detect +// a global service selector changing the button's containing block or intercepting a drawer close. +describe('SdLoadingService and SdButton browser rendering', () => { + let fixture: ComponentFixture; + let service: SdLoadingService; + let previousScroll: { left: number; top: number }; + const injectors = new Set(); + const foreignNodes = new Set(); + + beforeEach(async () => { + previousScroll = { left: window.scrollX, top: window.scrollY }; + await TestBed.configureTestingModule({ imports: [LoadingRenderingHost, NoopAnimationsModule] }).compileComponents(); + fixture = TestBed.createComponent(LoadingRenderingHost); + service = TestBed.inject(SdLoadingService); + fixture.detectChanges(); + await fixture.whenStable(); + // why: các test focus/scroll khác dùng chung document của Karma; overlay absolute cần + // viewport ban đầu xác định để hit-test không phụ thuộc vị trí cuộn của test chạy trước. + window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); + }); + + afterEach(() => { + for (const injector of injectors) injector.destroy(); + injectors.clear(); + TestBed.resetTestingModule(); + for (const node of foreignNodes) node.remove(); + foreignNodes.clear(); + window.scrollTo({ ...previousScroll, behavior: 'instant' }); + }); + + function element(selector: string, root: ParentNode = document): HTMLElement { + const result = root.querySelector(selector); + if (!result) throw new Error(`Missing rendered element: ${selector}`); + return result; + } + + function expectRect(target: HTMLElement, before: DOMRect): void { + const after = target.getBoundingClientRect(); + for (const key of ['x', 'y', 'width', 'height'] as const) { + expect(after[key]) + .withContext(`${target.dataset['testid'] ?? target.className}: ${key}`) + .toBeCloseTo(before[key], 1); + } + expect(after.width).toBeGreaterThan(0); + expect(after.height).toBeGreaterThan(0); + expect(getComputedStyle(target).position).not.toBe('absolute'); + expect(getComputedStyle(target).zIndex).not.toBe('99999'); + } + + function hitCenter(target: HTMLElement): HTMLElement { + const rect = target.getBoundingClientRect(); + const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + if (!(hit instanceof HTMLElement)) throw new Error('No hit target at rendered center'); + return hit; + } + + function expectOverlayHit(target: HTMLElement, overlay: HTMLElement): void { + expect(hitCenter(target)) + .withContext( + JSON.stringify({ + overlay: overlay.getBoundingClientRect().toJSON(), + target: target.getBoundingClientRect().toJSON(), + scroll: [window.scrollX, window.scrollY], + position: getComputedStyle(document.body).position, + pointerEvents: getComputedStyle(overlay).pointerEvents, + }) + ) + .toBe(overlay); + } + + async function openDrawer(): Promise { + fixture.componentInstance.drawer().open(); + fixture.detectChanges(); + await fixture.whenStable(); + const drawer = element(`#${fixture.componentInstance.drawer().id}`); + // Finish the real CSS entrance transition before measuring, without timers or mocked layout. + for (const animation of drawer.getAnimations()) animation.finish(); + return drawer; + } + + it('keeps the page header button geometry across repeated document body overlay starts and stops', () => { + const button = element('[data-testid="page-action"]'); + const before = button.getBoundingClientRect(); + for (let cycle = 0; cycle < 2; cycle++) { + const ref = service.start(); + expectRect(button, before); + const overlay = element('body > .sd-loading'); + expectOverlayHit(button, overlay); + ref.close(); + expectRect(button, before); + expect(button.contains(hitCenter(button))).toBeTrue(); + } + }); + + it('keeps the drawer header button geometry with a document body overlay and restores close hit testing', async () => { + const drawer = await openDrawer(); + const button = element('[data-testid="drawer-action"]', drawer); + const close = element('.sd-side-drawer-close-btn', drawer); + const before = button.getBoundingClientRect(); + const ref = service.start(); + expectRect(button, before); + // A deliberate full-document loading overlay retains its existing interaction lock. + expectOverlayHit(close, element('body > .sd-loading')); + ref.close(); + expectRect(button, before); + const hit = hitCenter(close); + expect(close.contains(hit)).toBeTrue(); + hit.click(); + expect(fixture.componentInstance.closes).toBe(1); + }); + + it('keeps both header buttons in place and the drawer close clickable while content bodies are loading', async () => { + const pageBeforeDrawer = element('[data-testid="page-action"]').getBoundingClientRect(); + const drawer = await openDrawer(); + const pageButton = element('[data-testid="page-action"]'); + const drawerButton = element('[data-testid="drawer-action"]', drawer); + const pageRect = pageButton.getBoundingClientRect(); + const drawerRect = drawerButton.getBoundingClientRect(); + const pageRef = service.start('#loading-rendering-content'); + const drawerRef = service.start(`#${drawer.id} .sd-side-drawer-body`); + expectRect(pageButton, pageRect); + expectRect(drawerButton, drawerRect); + drawerRef.close(); + expectRect(drawerButton, drawerRect); + const reopened = service.start(`#${drawer.id} .sd-side-drawer-body`); + const close = element('.sd-side-drawer-close-btn', drawer); + const hit = hitCenter(close); + expect(close.contains(hit)).toBeTrue(); + hit.click(); + expect(fixture.componentInstance.closes).toBe(1); + expect(fixture.componentInstance.drawer().isOpened()).toBeFalse(); + reopened.close(); + pageRef.close(); + // Closing the drawer restores the document scrollbar, so compare with the pre-open viewport. + expectRect(pageButton, pageBeforeDrawer); + }); + + for (const type of ['fill', 'light', 'outline', 'text'] as const) { + it(`preserves the ${type} button spinner and capture-phase click blocking with overlay styles installed`, () => { + fixture.componentInstance.type = type; + fixture.detectChanges(); + const ref = service.start('#loading-rendering-content'); + const host = element('[data-testid="page-action"]'); + const button = element('button', host); + const spinner = element('mat-spinner', host); + expect(host.classList.contains('sd-loading')).toBeTrue(); + expect(host.hasAttribute('data-sd-loading-overlay')).toBeFalse(); + expect(spinner.getBoundingClientRect().width).toBe(18); + expect(spinner.getBoundingClientRect().height).toBe(18); + expect(getComputedStyle(spinner).visibility).toBe('visible'); + expect(getComputedStyle(button).pointerEvents).toBe('none'); + expect(button.contains(hitCenter(button))).toBeFalse(); + hitCenter(host).click(); + button.click(); // Also exercises activation that bypasses CSS pointer-events. + expect(fixture.componentInstance.clicks).toBe(0); + ref.close(); + button.click(); + expect(fixture.componentInstance.clicks).toBe(0); + fixture.componentInstance.loading.set(false); + fixture.detectChanges(); + expect(host.querySelector('mat-spinner')).toBeNull(); + expect(getComputedStyle(button).pointerEvents).toBe('auto'); + hitCenter(button).click(); + expect(fixture.componentInstance.clicks).toBe(1); + }); + } + + it('only styles owned overlay markup and keeps it rendered until the last ref and service owner release', () => { + const foreign = document.createElement('div'); + foreign.className = 'sd-loading'; + foreign.innerHTML = '
'; + document.body.appendChild(foreign); + foreignNodes.add(foreign); + const spinner = element('.sd-loading-spinner', foreign); + const foreignPosition = getComputedStyle(foreign).position; + const spinnerPosition = getComputedStyle(spinner).position; + const firstInjector = createEnvironmentInjector([SdLoadingService], TestBed.inject(EnvironmentInjector)); + const secondInjector = createEnvironmentInjector([SdLoadingService], TestBed.inject(EnvironmentInjector)); + injectors.add(firstInjector); + injectors.add(secondInjector); + const firstService = firstInjector.get(SdLoadingService); + const first = firstService.start(); + const sameOwner = firstService.start(); + const otherOwner = secondInjector.get(SdLoadingService).start(); + const overlay = element('body > .sd-loading[data-sd-loading-overlay]'); + const overlaySpinner = element('.sd-loading-spinner', overlay); + expect(getComputedStyle(foreign).position).toBe(foreignPosition); + expect(getComputedStyle(spinner).position).toBe(spinnerPosition); + expect(getComputedStyle(overlay).position).toBe('absolute'); + expect(getComputedStyle(overlaySpinner).animationName).toBe('sd-loading-spin'); + expect(overlaySpinner.getBoundingClientRect().width).toBeGreaterThan(0); + const rect = overlay.getBoundingClientRect(); + first.close(); + first.close(); + expect(sameOwner.closed).toBeFalse(); + expect(overlay.isConnected).toBeTrue(); + firstInjector.destroy(); + injectors.delete(firstInjector); + expect(sameOwner.closed).toBeTrue(); + expect(otherOwner.closed).toBeFalse(); + expect(element('body > .sd-loading[data-sd-loading-overlay]')).toBe(overlay); + expect(overlay.getBoundingClientRect().toJSON()).toEqual(rect.toJSON()); + expect(getComputedStyle(overlay).zIndex).toBe('99999'); + expect(getComputedStyle(overlaySpinner).animationName).toBe('sd-loading-spin'); + otherOwner.close(); + expect(overlay.isConnected).toBeFalse(); + expect(foreign.isConnected).toBeTrue(); + secondInjector.destroy(); + injectors.delete(secondInjector); + expect(document.querySelector('style[data-sd-loading-styles]')).toBeNull(); + }); +}); diff --git a/versions/v22/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts b/versions/v22/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts index 4385fb572..f5968bc32 100644 --- a/versions/v22/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts +++ b/versions/v22/projects/sdcorejs-angular/services/loading/src/loading.service.spec.ts @@ -320,6 +320,7 @@ describe('SdLoadingService', () => { expect(host.getAttribute('aria-busy')).toBe('true'); expect(overlay).toBeTruthy(); expect(overlay?.hasAttribute('id')).toBeFalse(); + expect(overlay?.hasAttribute('data-sd-loading-overlay')).toBeTrue(); expect(overlay?.getAttribute('role')).toBe('status'); expect(overlay?.getAttribute('aria-live')).toBe('polite'); expect(overlay?.querySelector(':scope > .sd-loading-spinner')).toBeTruthy(); @@ -425,7 +426,7 @@ describe('SdLoadingService', () => { ownedService.start(); expect(externalStyle.textContent).toContain(externalContent); - expect(externalStyle.textContent).toContain('.sd-loading {'); + expect(externalStyle.textContent).toContain('.sd-loading[data-sd-loading-overlay] {'); expect(externalStyle.textContent).toContain('.sd-loading-spinner'); expect(externalStyle.textContent).toContain('@keyframes sd-loading-spin'); diff --git a/versions/v22/projects/sdcorejs-angular/services/loading/src/loading.service.ts b/versions/v22/projects/sdcorejs-angular/services/loading/src/loading.service.ts index 0090f40ee..ae21f440d 100644 --- a/versions/v22/projects/sdcorejs-angular/services/loading/src/loading.service.ts +++ b/versions/v22/projects/sdcorejs-angular/services/loading/src/loading.service.ts @@ -40,8 +40,12 @@ interface SdLoadingStyleRecord { } const SD_LOADING_STYLE_ATTRIBUTE = 'data-sd-loading-styles'; +const SD_LOADING_OVERLAY_ATTRIBUTE = 'data-sd-loading-overlay'; +// why: SdButton cũng dùng .sd-loading; chỉ overlay do service tạo được nhận CSS phủ vùng. +const SD_LOADING_OVERLAY_SELECTOR = `.sd-loading[${SD_LOADING_OVERLAY_ATTRIBUTE}]`; +const SD_LOADING_SPINNER_SELECTOR = `${SD_LOADING_OVERLAY_SELECTOR} > .sd-loading-spinner`; const SD_LOADING_STYLES = ` -.sd-loading { +${SD_LOADING_OVERLAY_SELECTOR} { position: absolute; inset: 0; width: 100%; @@ -51,7 +55,7 @@ const SD_LOADING_STYLES = ` z-index: 99999; } -.sd-loading-spinner { +${SD_LOADING_SPINNER_SELECTOR} { position: absolute; top: calc(50% - 2.5rem); left: calc(50% - 2.5rem); @@ -290,6 +294,7 @@ export class SdLoadingService { const spinner = this.#document.createElement('div'); container.classList.add('sd-loading'); + container.setAttribute(SD_LOADING_OVERLAY_ATTRIBUTE, ''); container.setAttribute('role', 'status'); container.setAttribute('aria-live', 'polite'); container.setAttribute('aria-label', 'Loading'); @@ -346,8 +351,8 @@ export class SdLoadingService { #ensureRequiredStyleText(record: SdLoadingStyleRecord): void { const currentText = record.element.textContent ?? ''; const hasRequiredRules = - currentText.includes('.sd-loading {') && - currentText.includes('.sd-loading-spinner') && + currentText.includes(`${SD_LOADING_OVERLAY_SELECTOR} {`) && + currentText.includes(`${SD_LOADING_SPINNER_SELECTOR} {`) && currentText.includes('@keyframes sd-loading-spin'); if (hasRequiredRules) return;