From bb85d795df8e500ecedca33bf93779b7d362d923 Mon Sep 17 00:00:00 2001 From: nghiatt15_onemount Date: Fri, 11 Sep 2026 15:44:29 +0700 Subject: [PATCH 1/2] feat(ui)!: add collapsible groups, compact overlays and remove charts Add collapsible form-builder groups and per-group Sidebar V3 expansion. Move hover-copy/query-builder popups outside clipped containers and use compact CDK menus for table command children and selection actions. Refine pill/segmented tab hover styling and align drawer, modal and bottom-sheet header, body and footer padding at 12px 16px. Remove bundled chart components and Chart.js; update consumer migration notes, Showcase examples and all four Angular-major workspaces. BREAKING CHANGE: chart components and exports are removed. Consumers must choose and configure their own chart library. sd-org-chart remains supported. Verification: v19 full suite (5372 tests), focused overlay suite (115 tests), menu regression suite (12 tests), full v19 lint, library and Showcase builds, repository script tests and four-line sync checks passed. Browser checks covered desktop/mobile padding, tab hover, menus, keyboard and focus. --- CHANGELOG.md | 27 +++ README.md | 2 +- README.npm.md | 6 +- .../generate-showcase-route-shells.test.mjs | 5 +- scripts/lint-phase.mjs | 1 - showcase/package-lock.json | 19 -- showcase/package.json | 1 - .../docs/core/documentation.registry.spec.ts | 13 +- .../app/docs/core/documentation.registry.ts | 13 -- .../generated/example-manifest.generated.ts | 30 +-- .../generated/example-sources.generated.ts | 219 ++---------------- .../components/chart/chart-demo.component.ts | 141 ----------- .../form-generic-demo.component.ts | 6 +- .../components/modal/modal-demo.component.ts | 2 +- .../components/table/table-demo.component.ts | 11 +- .../modules/layout/layout-demo.component.ts | 2 +- versions/v19/README.md | 2 +- versions/v19/SYNC-STATUS.md | 2 +- versions/v19/package-lock.json | 19 -- versions/v19/package.json | 1 - .../v19/projects/sdcorejs-angular/README.md | 6 +- .../components/chart/index.ts | 4 - .../components/chart/ng-package.json | 6 - .../components/chart/sd-chart.md | 140 ----------- .../chart/src/bar-chart.component.ts | 88 ------- .../chart/src/doughnut-chart.component.ts | 64 ----- .../chart/src/line-chart.component.ts | 88 ------- .../chart/src/pie-chart.component.ts | 64 ----- .../form-generic/sd-form-generic.md | 19 ++ .../attribute/group-attribute.component.html | 4 + .../group-attribute.component.spec.ts | 61 +++++ .../attribute/group-attribute.component.ts | 3 +- .../form-render/form-render.component.html | 2 +- .../form-render/form-render.component.spec.ts | 103 ++++++++ .../models/form-generic-component.model.ts | 1 + .../components/history/sd-history.md | 2 +- .../sdcorejs-angular/components/index.ts | 1 - .../components/modal/sd-modal.md | 4 +- .../components/modal/src/modal.component.scss | 13 +- .../query-builder/sd-query-builder.md | 5 +- .../src/query-builder-overlay.spec.ts | 135 +++++++++++ .../src/query-builder.component.html | 35 ++- .../src/query-builder.component.scss | 8 - .../src/query-builder.component.ts | 35 ++- .../components/side-drawer/sd-side-drawer.md | 6 +- .../src/side-drawer.component.scss | 14 +- .../sdcorejs-angular/components/tab/sd-tab.md | 2 +- .../tab/src/tab-group.component.scss | 20 +- .../components/table/sd-table.md | 4 +- .../components/action-menu/action-menu.scss | 67 ++++++ .../src/components/action-menu/action-menu.ts | 67 ++++++ .../command/desktop-command.component.html | 32 ++- .../command/desktop-command.component.scss | 15 +- .../command/desktop-command.component.spec.ts | 67 +++++- .../command/desktop-command.component.ts | 6 +- .../selector-action-layout.component.spec.ts | 48 ++++ .../selector-action.component.html | 119 ++++++---- .../selector-action.component.scss | 8 +- .../selector-action.component.ts | 5 +- .../src/sd-hover-copy-overlay.spec.ts | 96 ++++++++ .../src/sd-hover-copy.directive.spec.ts | 25 +- .../directives/src/sd-hover-copy.directive.ts | 55 ++++- .../directives/src/sd-hover-copy.md | 7 +- .../projects/sdcorejs-angular/i18n/src/en.ts | 1 + .../projects/sdcorejs-angular/i18n/src/ja.ts | 1 + .../projects/sdcorejs-angular/i18n/src/ko.ts | 1 + .../projects/sdcorejs-angular/i18n/src/vi.ts | 1 + .../projects/sdcorejs-angular/i18n/src/zh.ts | 1 + .../projects/sdcorejs-angular/karma.conf.js | 2 +- .../shared/menu-tree/menu-tree.component.html | 74 +++--- .../shared/menu-tree/menu-tree.component.scss | 20 +- .../menu-tree/menu-tree.component.spec.ts | 1 + .../shared/menu-tree/menu-tree.component.ts | 20 +- .../components/sidebar-v3/main.component.html | 2 + .../sidebar-v3/main.component.spec.ts | 136 ++++++++++- .../components/sidebar-v3/main.component.ts | 20 ++ .../modules/layout/sd-layout.md | 4 + .../projects/sdcorejs-angular/ng-package.json | 1 - .../projects/sdcorejs-angular/package.json | 2 - versions/v20/README.md | 2 +- versions/v20/SYNC-STATUS.md | 2 +- versions/v20/package-lock.json | 19 -- versions/v20/package.json | 1 - .../v20/projects/sdcorejs-angular/README.md | 6 +- .../components/chart/index.ts | 4 - .../components/chart/ng-package.json | 6 - .../components/chart/sd-chart.md | 140 ----------- .../chart/src/bar-chart.component.ts | 88 ------- .../chart/src/doughnut-chart.component.ts | 64 ----- .../chart/src/line-chart.component.ts | 88 ------- .../chart/src/pie-chart.component.ts | 64 ----- .../form-generic/sd-form-generic.md | 19 ++ .../attribute/group-attribute.component.html | 4 + .../group-attribute.component.spec.ts | 61 +++++ .../attribute/group-attribute.component.ts | 3 +- .../form-render/form-render.component.html | 2 +- .../form-render/form-render.component.spec.ts | 103 ++++++++ .../models/form-generic-component.model.ts | 1 + .../components/history/sd-history.md | 2 +- .../sdcorejs-angular/components/index.ts | 1 - .../components/modal/sd-modal.md | 4 +- .../components/modal/src/modal.component.scss | 13 +- .../query-builder/sd-query-builder.md | 5 +- .../src/query-builder-overlay.spec.ts | 135 +++++++++++ .../src/query-builder.component.html | 35 ++- .../src/query-builder.component.scss | 8 - .../src/query-builder.component.ts | 35 ++- .../components/side-drawer/sd-side-drawer.md | 6 +- .../src/side-drawer.component.scss | 14 +- .../sdcorejs-angular/components/tab/sd-tab.md | 2 +- .../tab/src/tab-group.component.scss | 20 +- .../components/table/sd-table.md | 4 +- .../components/action-menu/action-menu.scss | 67 ++++++ .../src/components/action-menu/action-menu.ts | 67 ++++++ .../command/desktop-command.component.html | 32 ++- .../command/desktop-command.component.scss | 15 +- .../command/desktop-command.component.spec.ts | 67 +++++- .../command/desktop-command.component.ts | 6 +- .../selector-action-layout.component.spec.ts | 48 ++++ .../selector-action.component.html | 119 ++++++---- .../selector-action.component.scss | 8 +- .../selector-action.component.ts | 5 +- .../src/sd-hover-copy-overlay.spec.ts | 96 ++++++++ .../src/sd-hover-copy.directive.spec.ts | 25 +- .../directives/src/sd-hover-copy.directive.ts | 55 ++++- .../directives/src/sd-hover-copy.md | 7 +- .../projects/sdcorejs-angular/i18n/src/en.ts | 1 + .../projects/sdcorejs-angular/i18n/src/ja.ts | 1 + .../projects/sdcorejs-angular/i18n/src/ko.ts | 1 + .../projects/sdcorejs-angular/i18n/src/vi.ts | 1 + .../projects/sdcorejs-angular/i18n/src/zh.ts | 1 + .../projects/sdcorejs-angular/karma.conf.js | 2 +- .../shared/menu-tree/menu-tree.component.html | 74 +++--- .../shared/menu-tree/menu-tree.component.scss | 20 +- .../menu-tree/menu-tree.component.spec.ts | 1 + .../shared/menu-tree/menu-tree.component.ts | 20 +- .../components/sidebar-v3/main.component.html | 2 + .../sidebar-v3/main.component.spec.ts | 136 ++++++++++- .../components/sidebar-v3/main.component.ts | 20 ++ .../modules/layout/sd-layout.md | 4 + .../projects/sdcorejs-angular/ng-package.json | 1 - .../projects/sdcorejs-angular/package.json | 2 - versions/v21/README.md | 2 +- versions/v21/SYNC-STATUS.md | 2 +- versions/v21/package-lock.json | 19 -- versions/v21/package.json | 1 - .../v21/projects/sdcorejs-angular/README.md | 6 +- .../components/chart/index.ts | 4 - .../components/chart/ng-package.json | 6 - .../components/chart/sd-chart.md | 140 ----------- .../chart/src/bar-chart.component.ts | 88 ------- .../chart/src/doughnut-chart.component.ts | 64 ----- .../chart/src/line-chart.component.ts | 88 ------- .../chart/src/pie-chart.component.ts | 64 ----- .../form-generic/sd-form-generic.md | 19 ++ .../attribute/group-attribute.component.html | 4 + .../group-attribute.component.spec.ts | 61 +++++ .../attribute/group-attribute.component.ts | 3 +- .../form-render/form-render.component.html | 2 +- .../form-render/form-render.component.spec.ts | 103 ++++++++ .../models/form-generic-component.model.ts | 1 + .../components/history/sd-history.md | 2 +- .../sdcorejs-angular/components/index.ts | 1 - .../components/modal/sd-modal.md | 4 +- .../components/modal/src/modal.component.scss | 13 +- .../query-builder/sd-query-builder.md | 5 +- .../src/query-builder-overlay.spec.ts | 135 +++++++++++ .../src/query-builder.component.html | 35 ++- .../src/query-builder.component.scss | 8 - .../src/query-builder.component.ts | 35 ++- .../components/side-drawer/sd-side-drawer.md | 6 +- .../src/side-drawer.component.scss | 14 +- .../sdcorejs-angular/components/tab/sd-tab.md | 2 +- .../tab/src/tab-group.component.scss | 20 +- .../components/table/sd-table.md | 4 +- .../components/action-menu/action-menu.scss | 67 ++++++ .../src/components/action-menu/action-menu.ts | 67 ++++++ .../command/desktop-command.component.html | 32 ++- .../command/desktop-command.component.scss | 15 +- .../command/desktop-command.component.spec.ts | 67 +++++- .../command/desktop-command.component.ts | 6 +- .../selector-action-layout.component.spec.ts | 48 ++++ .../selector-action.component.html | 119 ++++++---- .../selector-action.component.scss | 8 +- .../selector-action.component.ts | 5 +- .../src/sd-hover-copy-overlay.spec.ts | 96 ++++++++ .../src/sd-hover-copy.directive.spec.ts | 25 +- .../directives/src/sd-hover-copy.directive.ts | 55 ++++- .../directives/src/sd-hover-copy.md | 7 +- .../projects/sdcorejs-angular/i18n/src/en.ts | 1 + .../projects/sdcorejs-angular/i18n/src/ja.ts | 1 + .../projects/sdcorejs-angular/i18n/src/ko.ts | 1 + .../projects/sdcorejs-angular/i18n/src/vi.ts | 1 + .../projects/sdcorejs-angular/i18n/src/zh.ts | 1 + .../projects/sdcorejs-angular/karma.conf.js | 2 +- .../shared/menu-tree/menu-tree.component.html | 74 +++--- .../shared/menu-tree/menu-tree.component.scss | 20 +- .../menu-tree/menu-tree.component.spec.ts | 1 + .../shared/menu-tree/menu-tree.component.ts | 20 +- .../components/sidebar-v3/main.component.html | 2 + .../sidebar-v3/main.component.spec.ts | 136 ++++++++++- .../components/sidebar-v3/main.component.ts | 20 ++ .../modules/layout/sd-layout.md | 4 + .../projects/sdcorejs-angular/ng-package.json | 1 - .../projects/sdcorejs-angular/package.json | 2 - versions/v22/README.md | 2 +- versions/v22/SYNC-STATUS.md | 2 +- versions/v22/package-lock.json | 19 -- versions/v22/package.json | 1 - .../v22/projects/sdcorejs-angular/README.md | 6 +- .../components/chart/index.ts | 4 - .../components/chart/ng-package.json | 6 - .../components/chart/sd-chart.md | 140 ----------- .../chart/src/bar-chart.component.ts | 88 ------- .../chart/src/doughnut-chart.component.ts | 64 ----- .../chart/src/line-chart.component.ts | 88 ------- .../chart/src/pie-chart.component.ts | 64 ----- .../form-generic/sd-form-generic.md | 19 ++ .../attribute/group-attribute.component.html | 4 + .../group-attribute.component.spec.ts | 61 +++++ .../attribute/group-attribute.component.ts | 3 +- .../form-render/form-render.component.html | 2 +- .../form-render/form-render.component.spec.ts | 103 ++++++++ .../models/form-generic-component.model.ts | 1 + .../components/history/sd-history.md | 2 +- .../sdcorejs-angular/components/index.ts | 1 - .../components/modal/sd-modal.md | 4 +- .../components/modal/src/modal.component.scss | 13 +- .../query-builder/sd-query-builder.md | 5 +- .../src/query-builder-overlay.spec.ts | 137 +++++++++++ .../src/query-builder.component.html | 35 ++- .../src/query-builder.component.scss | 8 - .../src/query-builder.component.ts | 35 ++- .../components/side-drawer/sd-side-drawer.md | 6 +- .../src/side-drawer.component.scss | 14 +- .../sdcorejs-angular/components/tab/sd-tab.md | 2 +- .../tab/src/tab-group.component.scss | 20 +- .../components/table/sd-table.md | 4 +- .../components/action-menu/action-menu.scss | 67 ++++++ .../src/components/action-menu/action-menu.ts | 67 ++++++ .../command/desktop-command.component.html | 32 ++- .../command/desktop-command.component.scss | 15 +- .../command/desktop-command.component.spec.ts | 67 +++++- .../command/desktop-command.component.ts | 6 +- .../selector-action-layout.component.spec.ts | 48 ++++ .../selector-action.component.html | 119 ++++++---- .../selector-action.component.scss | 8 +- .../selector-action.component.ts | 5 +- .../src/sd-hover-copy-overlay.spec.ts | 98 ++++++++ .../src/sd-hover-copy.directive.spec.ts | 25 +- .../directives/src/sd-hover-copy.directive.ts | 55 ++++- .../directives/src/sd-hover-copy.md | 7 +- .../projects/sdcorejs-angular/i18n/src/en.ts | 1 + .../projects/sdcorejs-angular/i18n/src/ja.ts | 1 + .../projects/sdcorejs-angular/i18n/src/ko.ts | 1 + .../projects/sdcorejs-angular/i18n/src/vi.ts | 1 + .../projects/sdcorejs-angular/i18n/src/zh.ts | 1 + .../projects/sdcorejs-angular/karma.conf.js | 2 +- .../shared/menu-tree/menu-tree.component.html | 74 +++--- .../shared/menu-tree/menu-tree.component.scss | 20 +- .../menu-tree/menu-tree.component.spec.ts | 1 + .../shared/menu-tree/menu-tree.component.ts | 20 +- .../components/sidebar-v3/main.component.html | 2 + .../sidebar-v3/main.component.spec.ts | 136 ++++++++++- .../components/sidebar-v3/main.component.ts | 20 ++ .../modules/layout/sd-layout.md | 4 + .../projects/sdcorejs-angular/ng-package.json | 1 - .../projects/sdcorejs-angular/package.json | 2 - 268 files changed, 4803 insertions(+), 3159 deletions(-) delete mode 100644 showcase/src/app/pages/components/chart/chart-demo.component.ts delete mode 100644 versions/v19/projects/sdcorejs-angular/components/chart/index.ts delete mode 100644 versions/v19/projects/sdcorejs-angular/components/chart/ng-package.json delete mode 100644 versions/v19/projects/sdcorejs-angular/components/chart/sd-chart.md delete mode 100644 versions/v19/projects/sdcorejs-angular/components/chart/src/bar-chart.component.ts delete mode 100644 versions/v19/projects/sdcorejs-angular/components/chart/src/doughnut-chart.component.ts delete mode 100644 versions/v19/projects/sdcorejs-angular/components/chart/src/line-chart.component.ts delete mode 100644 versions/v19/projects/sdcorejs-angular/components/chart/src/pie-chart.component.ts create mode 100644 versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.spec.ts create mode 100644 versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder-overlay.spec.ts create mode 100644 versions/v19/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss create mode 100644 versions/v19/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts create mode 100644 versions/v19/projects/sdcorejs-angular/directives/src/sd-hover-copy-overlay.spec.ts delete mode 100644 versions/v20/projects/sdcorejs-angular/components/chart/index.ts delete mode 100644 versions/v20/projects/sdcorejs-angular/components/chart/ng-package.json delete mode 100644 versions/v20/projects/sdcorejs-angular/components/chart/sd-chart.md delete mode 100644 versions/v20/projects/sdcorejs-angular/components/chart/src/bar-chart.component.ts delete mode 100644 versions/v20/projects/sdcorejs-angular/components/chart/src/doughnut-chart.component.ts delete mode 100644 versions/v20/projects/sdcorejs-angular/components/chart/src/line-chart.component.ts delete mode 100644 versions/v20/projects/sdcorejs-angular/components/chart/src/pie-chart.component.ts create mode 100644 versions/v20/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.spec.ts create mode 100644 versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder-overlay.spec.ts create mode 100644 versions/v20/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss create mode 100644 versions/v20/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts create mode 100644 versions/v20/projects/sdcorejs-angular/directives/src/sd-hover-copy-overlay.spec.ts delete mode 100644 versions/v21/projects/sdcorejs-angular/components/chart/index.ts delete mode 100644 versions/v21/projects/sdcorejs-angular/components/chart/ng-package.json delete mode 100644 versions/v21/projects/sdcorejs-angular/components/chart/sd-chart.md delete mode 100644 versions/v21/projects/sdcorejs-angular/components/chart/src/bar-chart.component.ts delete mode 100644 versions/v21/projects/sdcorejs-angular/components/chart/src/doughnut-chart.component.ts delete mode 100644 versions/v21/projects/sdcorejs-angular/components/chart/src/line-chart.component.ts delete mode 100644 versions/v21/projects/sdcorejs-angular/components/chart/src/pie-chart.component.ts create mode 100644 versions/v21/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.spec.ts create mode 100644 versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder-overlay.spec.ts create mode 100644 versions/v21/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss create mode 100644 versions/v21/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts create mode 100644 versions/v21/projects/sdcorejs-angular/directives/src/sd-hover-copy-overlay.spec.ts delete mode 100644 versions/v22/projects/sdcorejs-angular/components/chart/index.ts delete mode 100644 versions/v22/projects/sdcorejs-angular/components/chart/ng-package.json delete mode 100644 versions/v22/projects/sdcorejs-angular/components/chart/sd-chart.md delete mode 100644 versions/v22/projects/sdcorejs-angular/components/chart/src/bar-chart.component.ts delete mode 100644 versions/v22/projects/sdcorejs-angular/components/chart/src/doughnut-chart.component.ts delete mode 100644 versions/v22/projects/sdcorejs-angular/components/chart/src/line-chart.component.ts delete mode 100644 versions/v22/projects/sdcorejs-angular/components/chart/src/pie-chart.component.ts create mode 100644 versions/v22/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.spec.ts create mode 100644 versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder-overlay.spec.ts create mode 100644 versions/v22/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss create mode 100644 versions/v22/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts create mode 100644 versions/v22/projects/sdcorejs-angular/directives/src/sd-hover-copy-overlay.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ad1c00c..149c0e13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ Format dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Maj ## [Unreleased] +### Changed (BREAKING for consumers) + +- Remove `@sdcorejs/angular/components/chart` and its `SdLineChartComponent`, `SdBarChartComponent`, `SdPieChartComponent`, and `SdDoughnutChartComponent` exports (also removed from the root/components barrels). Chart.js is no longer a Core UI dependency; consumers choose, install, and configure their own chart library. `sd-org-chart` remains available. + + Migration: remove the Core UI chart imports and replace ``, ``, ``, and `` in consumer templates. If retaining Chart.js, declare `chart.js` as a direct application dependency and own its canvas lifecycle and registration: + + ```diff + - import { SdLineChartComponent } from '@sdcorejs/angular/components/chart'; + - // Component imports: [SdLineChartComponent] + + import { Chart, registerables } from 'chart.js'; // installed by the consumer + + Chart.register(...registerables); + + // Create a Chart on the application's canvas and destroy it during teardown. + ``` + +### Added + +- Sidebar V3 supports independent expand/collapse of menu groups at every depth, with keyboard-accessible disclosure buttons. Group state survives search and sidebar rail toggles; navigation opens the active branch, and selecting a group icon in the rail opens that group. +- Form builder groups support `properties.collapsible` (default `false`), matching `sd-section`. Enable it to expand/collapse groups in preview and form render while preserving child values and validation. Group conditions remain limited to visibility; disabled-when rules belong to child fields. + +### Fixed + +- Table row-command children and selection-action menus use a compact white CDK menu with 36px rows, 18px icons and 8px corners. Preserve child filtering, disabled commands, callbacks, keyboard navigation, focus restoration and overlay placement outside clipped cells; touch targets remain 44px. +- Side drawer, dialog and bottom-sheet headers/footers share a white background and use compact 12px vertical and 16px horizontal padding. Body content also uses `12px 16px` padding on desktop and mobile, including its own top spacing below the separate header surface; mobile safe-area spacing and touch targets remain supported. +- Tab group pills clip hover/ripple effects to their rounded shape and retain the active fill on hover. Segmented tabs have a 4px gap so adjacent hover backgrounds remain separate. +- Hover-copy confirmation tooltips render in a CDK overlay above table rows and outside clipped cells. Scrolling, disabling and teardown clean up the overlay; initial rendering creates only one copy button. +- Query builder's add-condition/group menu renders outside ancestor overflow, keeping both actions visible in short modal bodies. Backdrop/Escape dismissal and focus restoration keep the surrounding modal open. + ## [2.8] - 2026-09-11 Release suffix `2.8` targets `19.2.8`, `20.2.8`, `21.2.8`, and `22.2.8`. diff --git a/README.md b/README.md index daffba2a6..161822cad 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ | Area | What the library provides | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| UI components | Navigation/data state, PDF preview, job progress, audit diff, modals, drawers, tabs, charts, and editors | +| UI components | Navigation/data state, PDF preview, job progress, audit diff, modals, drawers, tabs, and editors | | Data and workflow | Local/server tables, query builders, entity/tree pickers, unsaved-change guards, background tasks, file upload, and Excel import | | Form controls | Text/mask, number, time/time range, date/date range, datetime, select, autocomplete, checkbox, radio, switch, chip, color, and inline text | | Application services | Typed API/retry/cancel, ref-counted loading, graph-safe storage/cache/persistence, viewport signals, notifications, and Excel | diff --git a/README.npm.md b/README.npm.md index e84c41a42..234a3770d 100644 --- a/README.npm.md +++ b/README.npm.md @@ -91,18 +91,20 @@ Replace the timer with your typed service call and reset `saving` in a `finally` | Area | Representative APIs | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| UI components | Navigation/data state, PDF preview, job progress, audit diff, modals, drawers, tabs, charts, editors, and document tooling | +| UI components | Navigation/data state, PDF preview, job progress, audit diff, modals, drawers, tabs, editors, and document tooling | | Data and workflow | Local/server tables, entity/tree pickers, query builders, unsaved-change guards, background tasks, upload, and Excel import | | Form controls | Text/mask, number, time/time range, date/date range, datetime, select, autocomplete, checkbox, radio, switch, chip, and color | | Services | Typed API/retry/cancel, ref-counted loading, graph-safe persistence/cache/storage, viewport signals, notifications, and exports | | Portal modules | Auth, Keycloak, permission, layout, and icon modules | | Localization | Built-in `vi`, `en`, `ja`, `ko`, and `zh` catalogs, plus a synchronous custom-catalog provider | +Charting libraries are selected, installed, and configured by the consuming application. Core UI does not bundle a chart engine; `sd-org-chart` remains available for organizational hierarchies. + The [live showcase](https://sdcorejs.github.io/sdcorejs-angular/) demonstrates components, forms, and services. The [latest API manifest](https://sdcorejs.github.io/sdcorejs-angular/docs/latest/index.json) lists every published reference document without duplicating the full API here. ## Standalone and subpath imports -Prefer public leaf entry points so dependencies stay explicit and unused entry points can be removed from the application graph. The package declares `sideEffects: false`. +Prefer public leaf entry points so dependencies stay explicit and unused entry points can be removed from the application graph. Stylesheets and editor registration entry points are retained as side effects. ```ts import { SdButton } from '@sdcorejs/angular/components/button'; diff --git a/scripts/generate-showcase-route-shells.test.mjs b/scripts/generate-showcase-route-shells.test.mjs index 8dae79906..48755a3ea 100644 --- a/scripts/generate-showcase-route-shells.test.mjs +++ b/scripts/generate-showcase-route-shells.test.mjs @@ -184,7 +184,8 @@ test('matches the canonical v19 runtime registry and expected deployment route c .map(category => [category, pages.filter(page => page.category === category).length]) ); - assert.equal(pages.length, 99); + assert.equal(pages.length, 98); + assert.ok(!pages.some(page => page.category === 'components' && page.slug === 'chart')); assert.ok(pages.some(page => page.category === 'pipes-utilities' && page.slug === 'read-state')); // why: `routes.length` từng là hằng số 5941 và đã mục ngay khi release 1.6 ra (thực tế 7426). @@ -201,7 +202,7 @@ test('matches the canonical v19 runtime registry and expected deployment route c 'total = 1 root redirect + one identical block per release' ); assert.deepEqual(categoryCounts, { - components: 37, + components: 36, directives: 6, forms: 22, guides: 3, diff --git a/scripts/lint-phase.mjs b/scripts/lint-phase.mjs index 952e731f8..6c6cafeaf 100644 --- a/scripts/lint-phase.mjs +++ b/scripts/lint-phase.mjs @@ -30,7 +30,6 @@ const phasePaths = { 'projects/sdcorejs-angular/components/avatar', 'projects/sdcorejs-angular/components/badge', 'projects/sdcorejs-angular/components/button', - 'projects/sdcorejs-angular/components/chart', ], 'components-editor': [ 'projects/sdcorejs-angular/components/ckeditor-styles', diff --git a/showcase/package-lock.json b/showcase/package-lock.json index 23568242c..9c3114b1e 100644 --- a/showcase/package-lock.json +++ b/showcase/package-lock.json @@ -24,7 +24,6 @@ "@lucide/angular": "^1.22.0", "@sdcorejs/angular-material-datetime": "1.0.4", "@sdcorejs/utils": "1.1.4", - "chart.js": "^4.5.1", "ckeditor5": "48.0.0", "date-fns": "^3.6.0", "exceljs": "^4.4.0", @@ -4708,12 +4707,6 @@ "tslib": "2" } }, - "node_modules/@kurkle/color": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "license": "MIT" - }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -8291,18 +8284,6 @@ "dev": true, "license": "MIT" }, - "node_modules/chart.js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", - "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", diff --git a/showcase/package.json b/showcase/package.json index 58a2b3b50..52a32ea10 100644 --- a/showcase/package.json +++ b/showcase/package.json @@ -29,7 +29,6 @@ "@lucide/angular": "^1.22.0", "@sdcorejs/angular-material-datetime": "1.0.4", "@sdcorejs/utils": "1.1.4", - "chart.js": "^4.5.1", "ckeditor5": "48.0.0", "date-fns": "^3.6.0", "exceljs": "^4.4.0", diff --git a/showcase/src/app/docs/core/documentation.registry.spec.ts b/showcase/src/app/docs/core/documentation.registry.spec.ts index c68b4c61e..bcf888ac3 100644 --- a/showcase/src/app/docs/core/documentation.registry.spec.ts +++ b/showcase/src/app/docs/core/documentation.registry.spec.ts @@ -4,7 +4,7 @@ import { SHOWCASE_EXAMPLE_SOURCES } from '../generated/example-sources.generated const EXPECTED_CATEGORY_COUNTS = { guides: 3, - components: 37, + components: 36, forms: 22, directives: 6, services: 11, @@ -17,14 +17,15 @@ describe('documentation registry', () => { const publishedIds = DOC_PAGES.map(page => page.publishedDocId).filter(id => id !== null); const localOnlyPages = DOC_PAGES.filter(page => page.publishedDocId === null); - expect(DOC_PAGES).toHaveSize(99); - expect(new Set(publishedIds).size).toBe(99); + expect(DOC_PAGES).toHaveSize(98); + expect(new Set(publishedIds).size).toBe(98); + expect(DOC_PAGES.some(page => page.category === 'components' && page.slug === 'chart')).toBeFalse(); expect(localOnlyPages).toHaveSize(0); expect(DOC_CATEGORIES).toHaveSize(7); for (const category of DOC_CATEGORIES) { expect(getDocPagesByCategory(category)).withContext(category).toHaveSize(EXPECTED_CATEGORY_COUNTS[category]); } - expect(DOC_PAGES.reduce((total, page) => total + page.demoSectionCount, 0)).toBe(360); + expect(DOC_PAGES.reduce((total, page) => total + page.demoSectionCount, 0)).toBe(357); }); it('uses unique stable page ids and category/slug pairs', () => { @@ -46,7 +47,7 @@ describe('documentation registry', () => { const exampleIds = DOC_PAGES.flatMap(page => page.examples.map(example => example.id)); expect(new Set(exampleIds).size).toBe(exampleIds.length); - expect(exampleIds).toHaveSize(360); + expect(exampleIds).toHaveSize(357); expect(findDocPage('components', 'table')?.examples.map(example => example.sectionId)).toContain('example-quick-search'); expect(findDocPage('components', 'table')?.examples.map(example => example.sectionId)).toContain('example-external-filters'); for (const page of DOC_PAGES) { @@ -61,7 +62,7 @@ describe('documentation registry', () => { }); it('derives navigation groups and canonical/legacy lookup helpers from the registry', () => { - expect(DOC_NAV_GROUPS.map(group => group.pages.length)).toEqual([3, 37, 22, 6, 11, 10, 10]); + expect(DOC_NAV_GROUPS.map(group => group.pages.length)).toEqual([3, 36, 22, 6, 11, 10, 10]); expect(findDocPage('components', 'button')?.title).toBe('Button'); expect(findDocPage('directives', 'tooltip')?.publishedDocId).toBe('directives/src/sd-tooltip'); expect(findDocPage('components', 'generic')?.title).toBe('Form Generic'); diff --git a/showcase/src/app/docs/core/documentation.registry.ts b/showcase/src/app/docs/core/documentation.registry.ts index 55a038b53..04c57d586 100644 --- a/showcase/src/app/docs/core/documentation.registry.ts +++ b/showcase/src/app/docs/core/documentation.registry.ts @@ -202,19 +202,6 @@ const COMPONENT_PAGES = [ demoSectionCount: 3, loadComponent: () => import('../../pages/components/breadcrumb/breadcrumb-demo.component').then(m => m.BreadcrumbDemoComponent), }), - defineDocPage({ - category: 'components', - slug: 'chart', - title: 'Chart', - description: 'Biểu đồ Chart.js cho dashboard và báo cáo: line, bar, pie và doughnut.', - selector: 'sd-line-chart, sd-bar-chart, sd-pie-chart, sd-doughnut-chart', - importPath: '@sdcorejs/angular/components/chart', - publishedDocId: 'components/chart/sd-chart', - keywords: ['chart.js', 'dashboard', 'report', 'visualization'], - status: 'stable', - demoSectionCount: 3, - loadComponent: () => import('../../pages/components/chart/chart-demo.component').then(m => m.ChartDemoComponent), - }), defineDocPage({ category: 'components', slug: 'code-editor', diff --git a/showcase/src/app/docs/generated/example-manifest.generated.ts b/showcase/src/app/docs/generated/example-manifest.generated.ts index f4ac4012f..3fb43bb3a 100644 --- a/showcase/src/app/docs/generated/example-manifest.generated.ts +++ b/showcase/src/app/docs/generated/example-manifest.generated.ts @@ -48,9 +48,6 @@ export type ShowcaseExampleSourceKey = | "components/card/example-object-comparewith" | "components/card/example-single-selection" | "components/card/example-standalone-card" - | "components/chart/example-bieu-do-bar" - | "components/chart/example-bieu-do-line" - | "components/chart/example-bieu-do-pie-doughnut" | "components/code-editor/example-che-do-xem-json" | "components/code-editor/example-ngon-ngu-html" | "components/code-editor/example-ngon-ngu-typescript" @@ -694,27 +691,6 @@ export const SHOWCASE_EXAMPLE_MANIFEST = [ title: "Disabled và color", description: "Gồm selected+disabled, group disabled, inherited color, override color và standalone selected state.", }, - { - sourceKey: "components/chart/example-bieu-do-line", - pageKey: "components/chart", - sectionId: "example-bieu-do-line", - title: "Biểu đồ Line", - description: "Existing “Biểu đồ Line” scenario preserved from the showcase.", - }, - { - sourceKey: "components/chart/example-bieu-do-bar", - pageKey: "components/chart", - sectionId: "example-bieu-do-bar", - title: "Biểu đồ Bar", - description: "Existing “Biểu đồ Bar” scenario preserved from the showcase.", - }, - { - sourceKey: "components/chart/example-bieu-do-pie-doughnut", - pageKey: "components/chart", - sectionId: "example-bieu-do-pie-doughnut", - title: "Biểu đồ Pie & Doughnut", - description: "Existing “Biểu đồ Pie & Doughnut” scenario preserved from the showcase.", - }, { sourceKey: "components/code-editor/example-ngon-ngu-typescript", pageKey: "components/code-editor", @@ -804,7 +780,7 @@ export const SHOWCASE_EXAMPLE_MANIFEST = [ pageKey: "components/form-generic", sectionId: "example-builder-render", title: "Builder + Render", - description: "Existing “Builder + Render” scenario preserved from the showcase.", + description: "Group có thể thu gọn/mở rộng trong preview; điều kiện vô hiệu hóa được cấu hình trên từng trường con.", }, { sourceKey: "components/history/example-luong-phe-duyet", @@ -1574,7 +1550,7 @@ export const SHOWCASE_EXAMPLE_MANIFEST = [ pageKey: "components/table", sectionId: "example-lenh-dong-co-menu-con", title: "Lệnh dòng có menu con", - description: "Command có children sẽ render thành nút menu; các child command vẫn hỗ trợ icon, title, color, disabled, hidden và click theo từng row.", + description: "Command và action children dùng menu gọn nền trắng. Chọn dòng để thử action nhóm; các child command vẫn hỗ trợ icon, title, color, disabled, hidden và click theo từng row.", }, { sourceKey: "components/table/example-keo-tha-doi-thu-tu", @@ -2512,7 +2488,7 @@ export const SHOWCASE_EXAMPLE_MANIFEST = [ pageKey: "modules/layout", sectionId: "example-sidebar-v3-collapsible", title: "Sidebar V3 - Collapsible", - description: "Root icons, text-only nested branches and a compact search field on desktop; shared pinned and recent menus.", + description: "Bấm từng nhóm menu để đóng/mở các mục con trên desktop. Tìm kiếm vẫn thấy mục trong nhóm đã đóng; thu gọn cả sidebar giữ trạng thái từng nhóm.", }, { sourceKey: "pipes-utilities/empty/example-gia-tri-rong-hien-thi-dau-gach", diff --git a/showcase/src/app/docs/generated/example-sources.generated.ts b/showcase/src/app/docs/generated/example-sources.generated.ts index 2e7ded5a1..642335969 100644 --- a/showcase/src/app/docs/generated/example-sources.generated.ts +++ b/showcase/src/app/docs/generated/example-sources.generated.ts @@ -1538,165 +1538,6 @@ export class CardStandaloneExampleComponent { margin: 0; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; -}`, - }, - "components/chart": { - typescript: `import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { ChartData, ChartOptions } from 'chart.js'; -import { DemoPageComponent, DemoSectionComponent } from '../../../shared/demo-page.component'; -import { - SdLineChartComponent, - SdBarChartComponent, - SdPieChartComponent, - SdDoughnutChartComponent, -} from '@sdcorejs/angular/components/chart'; - -@Component({ - selector: 'app-chart-demo', - standalone: true, - imports: [ - DemoPageComponent, - DemoSectionComponent, - SdLineChartComponent, - SdBarChartComponent, - SdPieChartComponent, - SdDoughnutChartComponent, - ], - template: \` - - - @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-bieu-do-line') { - -
- -
-
- } - - @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-bieu-do-bar') { - -
- -
-
- } - - @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-bieu-do-pie-doughnut') { - -
-
- -
-
- -
-
-
- } -
- \`, - styles: [\` - .chart-box { - width: 100%; - height: 320px; - } - .chart-box.small { - height: 280px; - flex: 1; - min-width: 280px; - } - .row { - display: flex; - gap: 24px; - width: 100%; - flex-wrap: wrap; - } - \`], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ChartDemoComponent { - readonly lineData: ChartData<'line'> = { - labels: ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'], - datasets: [ - { - label: 'Doanh thu (tỷ VND)', - data: [12.5, 14.2, 13.8, 16.4, 18.1, 19.5], - borderColor: '#005cbb', - backgroundColor: 'rgba(0, 92, 187, 0.14)', - fill: true, - tension: 0.35, - }, - { - label: 'Chi phí (tỷ VND)', - data: [9.4, 10.1, 10.6, 11.0, 11.8, 12.3], - borderColor: '#e64a19', - backgroundColor: 'rgba(230, 74, 25, 0.10)', - fill: true, - tension: 0.35, - }, - ], - }; - - readonly lineOptions: ChartOptions<'line'> = { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { position: 'bottom' } }, - }; - - readonly barData: ChartData<'bar'> = { - labels: ['Công nghệ', 'Kinh doanh', 'Nhân sự', 'Tài chính', 'Marketing'], - datasets: [ - { - label: 'KPI đạt được (%)', - data: [92, 110, 85, 96, 102], - backgroundColor: ['#005cbb', '#2e7d32', '#f9a825', '#6a1b9a', '#0277bd'], - }, - ], - }; - - readonly barOptions: ChartOptions<'bar'> = { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - }; - - readonly pieData: ChartData<'pie'> = { - labels: ['Nhân sự', 'Marketing', 'Vận hành', 'Khác'], - datasets: [ - { - data: [42, 23, 25, 10], - backgroundColor: ['#005cbb', '#e64a19', '#2e7d32', '#9e9e9e'], - }, - ], - }; - - readonly doughnutData: ChartData<'doughnut'> = { - labels: ['Nhân sự', 'Marketing', 'Vận hành', 'Khác'], - datasets: [ - { - data: [42, 23, 25, 10], - backgroundColor: ['#005cbb', '#e64a19', '#2e7d32', '#9e9e9e'], - }, - ], - }; -} -`, - scss: `.chart-box { - width: 100%; - height: 320px; -} -.chart-box.small { - height: 280px; - flex: 1; - min-width: 280px; -} -.row { - display: flex; - gap: 24px; - width: 100%; - flex-wrap: wrap; }`, }, "components/code-editor": { @@ -2284,7 +2125,7 @@ const SEED: SdFormGeneric = { type: 'group', label: 'Địa chỉ giao hàng', layout: { columns: '12' }, - properties: { icon: 'inventory_2', color: 'secondary' }, + properties: { icon: 'inventory_2', color: 'secondary', collapsible: true }, components: [ { id: 'g1c1', @@ -2461,7 +2302,9 @@ const DRAG_DROP_POPUP_SEED: SdFormGeneric = { title="Form Generic" description="Dynamic form builder and renderer with schema-safe drag/drop, group detail editing, query-builder conditions, and runtime preview."> @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-builder-render') { - +
@@ -3651,7 +3494,7 @@ import { SdSection, SdSectionItem } from '@sdcorejs/angular/components/section'; heading="Basic modal + footer right" [props]="[ { name: 'sdFooterRight', value: 'template' }, - { name: 'body inset', value: '24px / 16px' }, + { name: 'body padding', value: '12px 16px' }, ]"> @@ -6863,7 +6706,7 @@ const TASKS: Task[] = [ { name: 'command.commands[].children', value: 'SdTableCommandNormal[]' }, { name: 'command.align', value: 'right' } ]" - note="Command có children sẽ render thành nút menu; các child command vẫn hỗ trợ icon, title, color, disabled, hidden và click theo từng row."> + note="Command và action children dùng menu gọn nền trắng. Chọn dòng để thử action nhóm; các child command vẫn hỗ trợ icon, title, color, disabled, hidden và click theo từng row.">
{{ row.code }}
{{ row.name }}
@@ -7430,6 +7273,15 @@ export class TableDemoComponent { type: 'local', items: () => PRODUCTS, filler: { enabled: true }, + selector: { + visible: true, + actions: [{ + title: 'Xử lý đã chọn', icon: 'checklist', children: [ + { title: 'Kiểm kho', icon: 'inventory_2', click: rows => alert('Kiểm kho ' + rows?.length + ' sản phẩm') }, + { title: 'Xóa', icon: 'delete', color: 'error', click: rows => alert('Xóa ' + rows?.length + ' sản phẩm') }, + ], + }], + }, command: { align: 'right', commands: [ @@ -12415,7 +12267,7 @@ export class LayoutVersionPreviewComponent { selected(): {{ lastSelected() }}

`, - }, - "components/chart/example-bieu-do-bar": { - ...SHOWCASE_PAGE_SOURCES["components/chart"], - html: ` -
- -
-
`, - }, - "components/chart/example-bieu-do-line": { - ...SHOWCASE_PAGE_SOURCES["components/chart"], - html: ` -
- -
-
`, - }, - "components/chart/example-bieu-do-pie-doughnut": { - ...SHOWCASE_PAGE_SOURCES["components/chart"], - html: ` -
-
- -
-
- -
-
-
`, }, "components/code-editor/example-che-do-xem-json": { ...SHOWCASE_PAGE_SOURCES["components/code-editor"], @@ -15713,7 +15536,9 @@ export const SHOWCASE_EXAMPLE_SOURCES = { }, "components/form-generic/example-builder-render": { ...SHOWCASE_PAGE_SOURCES["components/form-generic"], - html: ` + html: `
@@ -15998,7 +15823,7 @@ export const SHOWCASE_EXAMPLE_SOURCES = { heading="Basic modal + footer right" [props]="[ { name: 'sdFooterRight', value: 'template' }, - { name: 'body inset', value: '24px / 16px' }, + { name: 'body padding', value: '12px 16px' }, ]"> @@ -17568,7 +17393,7 @@ export const SHOWCASE_EXAMPLE_SOURCES = { { name: 'command.commands[].children', value: 'SdTableCommandNormal[]' }, { name: 'command.align', value: 'right' } ]" - note="Command có children sẽ render thành nút menu; các child command vẫn hỗ trợ icon, title, color, disabled, hidden và click theo từng row."> + note="Command và action children dùng menu gọn nền trắng. Chọn dòng để thử action nhóm; các child command vẫn hỗ trợ icon, title, color, disabled, hidden và click theo từng row.">
{{ row.code }}
{{ row.name }}
@@ -19638,7 +19463,7 @@ export const SHOWCASE_EXAMPLE_SOURCES = { html: ` - - @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-bieu-do-line') { - -
- -
-
- } - - @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-bieu-do-bar') { - -
- -
-
- } - - @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-bieu-do-pie-doughnut') { - -
-
- -
-
- -
-
-
- } - - `, - styles: [` - .chart-box { - width: 100%; - height: 320px; - } - .chart-box.small { - height: 280px; - flex: 1; - min-width: 280px; - } - .row { - display: flex; - gap: 24px; - width: 100%; - flex-wrap: wrap; - } - `], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ChartDemoComponent { - readonly lineData: ChartData<'line'> = { - labels: ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'], - datasets: [ - { - label: 'Doanh thu (tỷ VND)', - data: [12.5, 14.2, 13.8, 16.4, 18.1, 19.5], - borderColor: '#005cbb', - backgroundColor: 'rgba(0, 92, 187, 0.14)', - fill: true, - tension: 0.35, - }, - { - label: 'Chi phí (tỷ VND)', - data: [9.4, 10.1, 10.6, 11.0, 11.8, 12.3], - borderColor: '#e64a19', - backgroundColor: 'rgba(230, 74, 25, 0.10)', - fill: true, - tension: 0.35, - }, - ], - }; - - readonly lineOptions: ChartOptions<'line'> = { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { position: 'bottom' } }, - }; - - readonly barData: ChartData<'bar'> = { - labels: ['Công nghệ', 'Kinh doanh', 'Nhân sự', 'Tài chính', 'Marketing'], - datasets: [ - { - label: 'KPI đạt được (%)', - data: [92, 110, 85, 96, 102], - backgroundColor: ['#005cbb', '#2e7d32', '#f9a825', '#6a1b9a', '#0277bd'], - }, - ], - }; - - readonly barOptions: ChartOptions<'bar'> = { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - }; - - readonly pieData: ChartData<'pie'> = { - labels: ['Nhân sự', 'Marketing', 'Vận hành', 'Khác'], - datasets: [ - { - data: [42, 23, 25, 10], - backgroundColor: ['#005cbb', '#e64a19', '#2e7d32', '#9e9e9e'], - }, - ], - }; - - readonly doughnutData: ChartData<'doughnut'> = { - labels: ['Nhân sự', 'Marketing', 'Vận hành', 'Khác'], - datasets: [ - { - data: [42, 23, 25, 10], - backgroundColor: ['#005cbb', '#e64a19', '#2e7d32', '#9e9e9e'], - }, - ], - }; -} diff --git a/showcase/src/app/pages/components/form-generic/form-generic-demo.component.ts b/showcase/src/app/pages/components/form-generic/form-generic-demo.component.ts index ffc502c53..bda0c1220 100644 --- a/showcase/src/app/pages/components/form-generic/form-generic-demo.component.ts +++ b/showcase/src/app/pages/components/form-generic/form-generic-demo.component.ts @@ -146,7 +146,7 @@ const SEED: SdFormGeneric = { type: 'group', label: 'Địa chỉ giao hàng', layout: { columns: '12' }, - properties: { icon: 'inventory_2', color: 'secondary' }, + properties: { icon: 'inventory_2', color: 'secondary', collapsible: true }, components: [ { id: 'g1c1', @@ -323,7 +323,9 @@ const DRAG_DROP_POPUP_SEED: SdFormGeneric = { title="Form Generic" description="Dynamic form builder and renderer with schema-safe drag/drop, group detail editing, query-builder conditions, and runtime preview."> @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-builder-render') { - +
diff --git a/showcase/src/app/pages/components/modal/modal-demo.component.ts b/showcase/src/app/pages/components/modal/modal-demo.component.ts index 114ad1645..8be600cbf 100644 --- a/showcase/src/app/pages/components/modal/modal-demo.component.ts +++ b/showcase/src/app/pages/components/modal/modal-demo.component.ts @@ -19,7 +19,7 @@ import { SdSection, SdSectionItem } from '@sdcorejs/angular/components/section'; heading="Basic modal + footer right" [props]="[ { name: 'sdFooterRight', value: 'template' }, - { name: 'body inset', value: '24px / 16px' }, + { name: 'body padding', value: '12px 16px' }, ]"> diff --git a/showcase/src/app/pages/components/table/table-demo.component.ts b/showcase/src/app/pages/components/table/table-demo.component.ts index 42aa09657..a88bd9d94 100644 --- a/showcase/src/app/pages/components/table/table-demo.component.ts +++ b/showcase/src/app/pages/components/table/table-demo.component.ts @@ -480,7 +480,7 @@ const TASKS: Task[] = [ { name: 'command.commands[].children', value: 'SdTableCommandNormal[]' }, { name: 'command.align', value: 'right' } ]" - note="Command có children sẽ render thành nút menu; các child command vẫn hỗ trợ icon, title, color, disabled, hidden và click theo từng row."> + note="Command và action children dùng menu gọn nền trắng. Chọn dòng để thử action nhóm; các child command vẫn hỗ trợ icon, title, color, disabled, hidden và click theo từng row.">
{{ row.code }}
{{ row.name }}
@@ -1047,6 +1047,15 @@ export class TableDemoComponent { type: 'local', items: () => PRODUCTS, filler: { enabled: true }, + selector: { + visible: true, + actions: [{ + title: 'Xử lý đã chọn', icon: 'checklist', children: [ + { title: 'Kiểm kho', icon: 'inventory_2', click: rows => alert('Kiểm kho ' + rows?.length + ' sản phẩm') }, + { title: 'Xóa', icon: 'delete', color: 'error', click: rows => alert('Xóa ' + rows?.length + ' sản phẩm') }, + ], + }], + }, command: { align: 'right', commands: [ diff --git a/showcase/src/app/pages/modules/layout/layout-demo.component.ts b/showcase/src/app/pages/modules/layout/layout-demo.component.ts index 6277f7213..025bf1902 100644 --- a/showcase/src/app/pages/modules/layout/layout-demo.component.ts +++ b/showcase/src/app/pages/modules/layout/layout-demo.component.ts @@ -384,7 +384,7 @@ export class LayoutVersionPreviewComponent { =8" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", diff --git a/versions/v19/package.json b/versions/v19/package.json index 8ccb30362..c527d8a9c 100644 --- a/versions/v19/package.json +++ b/versions/v19/package.json @@ -35,7 +35,6 @@ "@lucide/angular": "^1.22.0", "@sdcorejs/angular-material-datetime": "1.0.4", "@sdcorejs/utils": "1.1.4", - "chart.js": "^4.5.1", "ckeditor5": "48.0.0", "date-fns": "^3.6.0", "exceljs": "^4.4.0", diff --git a/versions/v19/projects/sdcorejs-angular/README.md b/versions/v19/projects/sdcorejs-angular/README.md index e84c41a42..234a3770d 100644 --- a/versions/v19/projects/sdcorejs-angular/README.md +++ b/versions/v19/projects/sdcorejs-angular/README.md @@ -91,18 +91,20 @@ Replace the timer with your typed service call and reset `saving` in a `finally` | Area | Representative APIs | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| UI components | Navigation/data state, PDF preview, job progress, audit diff, modals, drawers, tabs, charts, editors, and document tooling | +| UI components | Navigation/data state, PDF preview, job progress, audit diff, modals, drawers, tabs, editors, and document tooling | | Data and workflow | Local/server tables, entity/tree pickers, query builders, unsaved-change guards, background tasks, upload, and Excel import | | Form controls | Text/mask, number, time/time range, date/date range, datetime, select, autocomplete, checkbox, radio, switch, chip, and color | | Services | Typed API/retry/cancel, ref-counted loading, graph-safe persistence/cache/storage, viewport signals, notifications, and exports | | Portal modules | Auth, Keycloak, permission, layout, and icon modules | | Localization | Built-in `vi`, `en`, `ja`, `ko`, and `zh` catalogs, plus a synchronous custom-catalog provider | +Charting libraries are selected, installed, and configured by the consuming application. Core UI does not bundle a chart engine; `sd-org-chart` remains available for organizational hierarchies. + The [live showcase](https://sdcorejs.github.io/sdcorejs-angular/) demonstrates components, forms, and services. The [latest API manifest](https://sdcorejs.github.io/sdcorejs-angular/docs/latest/index.json) lists every published reference document without duplicating the full API here. ## Standalone and subpath imports -Prefer public leaf entry points so dependencies stay explicit and unused entry points can be removed from the application graph. The package declares `sideEffects: false`. +Prefer public leaf entry points so dependencies stay explicit and unused entry points can be removed from the application graph. Stylesheets and editor registration entry points are retained as side effects. ```ts import { SdButton } from '@sdcorejs/angular/components/button'; diff --git a/versions/v19/projects/sdcorejs-angular/components/chart/index.ts b/versions/v19/projects/sdcorejs-angular/components/chart/index.ts deleted file mode 100644 index 7551d46c3..000000000 --- a/versions/v19/projects/sdcorejs-angular/components/chart/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './src/bar-chart.component'; -export * from './src/line-chart.component'; -export * from './src/pie-chart.component'; -export * from './src/doughnut-chart.component'; diff --git a/versions/v19/projects/sdcorejs-angular/components/chart/ng-package.json b/versions/v19/projects/sdcorejs-angular/components/chart/ng-package.json deleted file mode 100644 index 19e3f2bcb..000000000 --- a/versions/v19/projects/sdcorejs-angular/components/chart/ng-package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", - "lib": { - "entryFile": "index.ts" - } -} diff --git a/versions/v19/projects/sdcorejs-angular/components/chart/sd-chart.md b/versions/v19/projects/sdcorejs-angular/components/chart/sd-chart.md deleted file mode 100644 index 8df787871..000000000 --- a/versions/v19/projects/sdcorejs-angular/components/chart/sd-chart.md +++ /dev/null @@ -1,140 +0,0 @@ -# `` / `` / `` / `` - -**Type**: Component family (4 separate components, one per chart type) -**Selectors**: `sd-bar-chart`, `sd-line-chart`, `sd-pie-chart`, `sd-doughnut-chart` -**Import path**: `@sdcorejs/angular/components/chart` (or barrel: `@sdcorejs/angular/components`) -**Classes**: `SdBarChartComponent`, `SdLineChartComponent`, `SdPieChartComponent`, `SdDoughnutChartComponent` -**Standalone**: yes -**Change detection**: `OnPush` -**Underlying engine**: [Chart.js](https://www.chartjs.org/) (all `registerables` registered) - -## One-line purpose -Thin signal-based wrappers around Chart.js — render bar / line / pie / doughnut visualizations from declarative `data` / `options` / `plugins` inputs, with auto-update on signal changes. - -## When to use -- Dashboards: KPI cards, time-series, breakdown by category -- Reports: monthly/quarterly comparisons, share-of-total -- Analytics widgets inside detail pages -- Any place a Chart.js chart fits — these wrappers don't add domain styling, they relay Chart.js config - -## When NOT to use -- For sparkline / inline trend cells in tables — use a smaller dedicated component -- For non-Chart.js visualizations (D3, ECharts, mermaid) — use those libraries directly -- For numeric "stat tiles" without a graph — use a card + typography -- When data updates faster than ~10 fps — Chart.js redraw cost will dominate; consider canvas-level rendering - -## Inputs (identical shape across all 4 charts; the generic chart-type changes) - -### `` -| Name | Type | Default | Notes | -| --- | --- | --- | --- | -| `data` | `ChartData<'bar'>` | (required) | Chart.js v4 bar dataset (`labels`, `datasets[]`). | -| `options` | `ChartOptions<'bar'>` | `undefined` | Chart.js options (scales, plugins, legend, tooltips, …). | -| `plugins` | `Plugin<'bar'>[]` | `[]` | Chart.js plugins (chart-local; `Chart.register(...registerables)` is already called globally). | - -### `` -| Name | Type | Default | Notes | -| --- | --- | --- | --- | -| `data` | `ChartData<'line'>` | (required) | Chart.js v4 line dataset. | -| `options` | `ChartOptions<'line'>` | `undefined` | Chart.js options. | -| `plugins` | `Plugin<'line'>[]` | `[]` | Chart-local plugins. | - -### `` -| Name | Type | Default | Notes | -| --- | --- | --- | --- | -| `data` | `ChartData<'pie'>` | (required) | Chart.js v4 pie dataset. | -| `options` | `ChartOptions<'pie'>` | `undefined` | Chart.js options. | -| `plugins` | `Plugin<'pie'>[]` | `[]` | Chart-local plugins. | - -### `` -| Name | Type | Default | Notes | -| --- | --- | --- | --- | -| `data` | `ChartData<'doughnut'>` | (required) | Chart.js v4 doughnut dataset. | -| `options` | `ChartOptions<'doughnut'>` | `undefined` | Chart.js options. | -| `plugins` | `Plugin<'doughnut'>[]` | `[]` | Chart-local plugins. | - -## Outputs -None. To respond to user interaction, attach Chart.js plugins or pass `options.onClick` / `options.onHover` callbacks via `options`. - -## Content projection (slots) -None — these are pure canvas wrappers. - -## Behavior notes -- An internal `effect()` watches `data`/`options` signals and calls `chart.update()` on change (no destroy/recreate, so animations are smooth). -- The chart instance is created in `ngOnInit` and destroyed in `ngOnDestroy`. -- **Bar and line** add a horizontal-scroll wrapper when `data.labels.length > 15`: the inner div gets `min-width = labels.length * 50 px` and the outer wrapper gets `overflow-x: auto`. This avoids cramped axes on dense series. -- **Pie and doughnut** are 100% width / 100% height of their host — set the host's size via CSS. - -## Visual cues -- **Bar**: vertical bars over a horizontal label axis; horizontally scrollable when many labels. -- **Line**: time-series line(s) with optional area fill (per Chart.js config); horizontally scrollable when many labels. -- **Pie**: full-circle slice diagram, 100%-of-total proportions. -- **Doughnut**: same as pie with a hollow center (good for a centered KPI label). -- All four obey `data.datasets[*].backgroundColor` / `borderColor` — colors come from the data, not the component. - -## Examples - -### 1. Monthly revenue bar chart -```ts -revenueData = signal>({ - labels: ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'], - datasets: [{ - label: 'Doanh thu (triệu đồng)', - data: [120, 135, 110, 160, 180, 150], - backgroundColor: '#3498db', - }], -}); -revenueOptions: ChartOptions<'bar'> = { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { position: 'top' } }, -}; -``` -```html -
- -
-``` - -### 2. Multi-series line chart -```html -
- -
-``` - -### 3. Pie chart for category breakdown -```ts -breakdownData = signal>({ - labels: ['Sản phẩm A', 'Sản phẩm B', 'Sản phẩm C'], - datasets: [{ data: [45, 30, 25], backgroundColor: ['#1abc9c', '#3498db', '#e67e22'] }], -}); -``` -```html -
- -
-``` - -### 4. Doughnut KPI with center label (via plugin) -```html -
- - -
-``` - -## Anti-patterns -- Forgetting to set a height on the host — Chart.js needs a sized container to render; `height: 0` produces a blank canvas -- Passing brand-new object references on every change-detection cycle for `options` — every change triggers `chart.update()`; memoize when possible -- Using these wrappers for fundamentally different chart types (radar, scatter, polar) without adding a corresponding component — these 4 selectors are typed to specific chart types -- Manually calling `Chart.register(...)` again — already registered at module load -- Putting these inside a `*ngIf` that toggles rapidly — destroys/recreates the canvas; consider keeping mounted and toggling visibility - -## Related -- `` — common host for charts on a dashboard -- `` — to switch between multiple chart views -- `` — page shell containing reporting dashboards diff --git a/versions/v19/projects/sdcorejs-angular/components/chart/src/bar-chart.component.ts b/versions/v19/projects/sdcorejs-angular/components/chart/src/bar-chart.component.ts deleted file mode 100644 index 908389590..000000000 --- a/versions/v19/projects/sdcorejs-angular/components/chart/src/bar-chart.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - InputSignal, - OnDestroy, - OnInit, - effect, - input, - computed, - viewChild, -} from '@angular/core'; -import { Chart, ChartData, ChartOptions, Plugin, registerables } from 'chart.js'; - -Chart.register(...registerables); - -@Component({ - selector: 'sd-bar-chart', - template: ` -
-
- -
-
- `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, -}) -export class SdBarChartComponent implements OnInit, OnDestroy { - canvas = viewChild.required>('canvas'); - - data: InputSignal> = input.required>(); - options: InputSignal | undefined> = input>(); - plugins: InputSignal[]> = input[]>([]); - - isScrollable = computed(() => { - const labelsLength = this.data()?.labels?.length || 0; - return labelsLength > 15; - }); - - computedMinWidth = computed(() => { - if (!this.isScrollable()) return null; - const labelsLength = this.data()?.labels?.length || 0; - return labelsLength * 50; - }); - - chart: Chart<'bar'> | undefined; - - constructor() { - effect(() => { - const currentData = this.data(); - const currentOptions = this.options(); - - if (this.chart) { - this.chart.data = currentData as any; - if (currentOptions) { - this.chart.options = currentOptions; - } - this.chart.update(); - } - }); - } - - ngOnInit() { - this.#initChart(); - } - - ngOnDestroy() { - if (this.chart) { - this.chart.destroy(); - } - } - - #initChart() { - if (this.chart) { - this.chart.destroy(); - } - const ctx = this.canvas().nativeElement.getContext('2d'); - if (ctx) { - this.chart = new Chart(ctx, { - type: 'bar', - data: this.data() as any, - options: this.options() || {}, - plugins: this.plugins(), - }); - } - } -} diff --git a/versions/v19/projects/sdcorejs-angular/components/chart/src/doughnut-chart.component.ts b/versions/v19/projects/sdcorejs-angular/components/chart/src/doughnut-chart.component.ts deleted file mode 100644 index d166ee3a8..000000000 --- a/versions/v19/projects/sdcorejs-angular/components/chart/src/doughnut-chart.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ChangeDetectionStrategy, Component, ElementRef, InputSignal, OnDestroy, OnInit, effect, input, viewChild } from '@angular/core'; -import { Chart, ChartData, ChartOptions, Plugin, registerables } from 'chart.js'; - -Chart.register(...registerables); - -@Component({ - selector: 'sd-doughnut-chart', - template: ` -
- -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, -}) -export class SdDoughnutChartComponent implements OnInit, OnDestroy { - canvas = viewChild.required>('canvas'); - - data: InputSignal> = input.required>(); - options: InputSignal | undefined> = input>(); - plugins: InputSignal[]> = input[]>([]); - - chart: Chart<'doughnut'> | undefined; - - constructor() { - effect(() => { - const currentData = this.data(); - const currentOptions = this.options(); - - if (this.chart) { - this.chart.data = currentData; - if (currentOptions) { - this.chart.options = currentOptions; - } - this.chart.update(); - } - }); - } - - ngOnInit() { - this.#initChart(); - } - - ngOnDestroy() { - if (this.chart) { - this.chart.destroy(); - } - } - - #initChart() { - if (this.chart) { - this.chart.destroy(); - } - const ctx = this.canvas().nativeElement.getContext('2d'); - if (ctx) { - this.chart = new Chart(ctx, { - type: 'doughnut', - data: this.data(), - options: this.options() || {}, - plugins: this.plugins(), - }); - } - } -} diff --git a/versions/v19/projects/sdcorejs-angular/components/chart/src/line-chart.component.ts b/versions/v19/projects/sdcorejs-angular/components/chart/src/line-chart.component.ts deleted file mode 100644 index 20512d49b..000000000 --- a/versions/v19/projects/sdcorejs-angular/components/chart/src/line-chart.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - InputSignal, - OnDestroy, - OnInit, - effect, - input, - computed, - viewChild, -} from '@angular/core'; -import { Chart, ChartData, ChartOptions, Plugin, registerables } from 'chart.js'; - -Chart.register(...registerables); - -@Component({ - selector: 'sd-line-chart', - template: ` -
-
- -
-
- `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, -}) -export class SdLineChartComponent implements OnInit, OnDestroy { - canvas = viewChild.required>('canvas'); - - data: InputSignal> = input.required>(); - options: InputSignal | undefined> = input>(); - plugins: InputSignal[]> = input[]>([]); - - isScrollable = computed(() => { - const labelsLength = this.data()?.labels?.length || 0; - return labelsLength > 15; - }); - - computedMinWidth = computed(() => { - if (!this.isScrollable()) return null; - const labelsLength = this.data()?.labels?.length || 0; - return labelsLength * 50; - }); - - chart: Chart<'line'> | undefined; - - constructor() { - effect(() => { - const currentData = this.data(); - const currentOptions = this.options(); - - if (this.chart) { - this.chart.data = currentData; - if (currentOptions) { - this.chart.options = currentOptions; - } - this.chart.update(); - } - }); - } - - ngOnInit() { - this.#initChart(); - } - - ngOnDestroy() { - if (this.chart) { - this.chart.destroy(); - } - } - - #initChart() { - if (this.chart) { - this.chart.destroy(); - } - const ctx = this.canvas().nativeElement.getContext('2d'); - if (ctx) { - this.chart = new Chart(ctx, { - type: 'line', - data: this.data(), - options: this.options() || {}, - plugins: this.plugins(), - }); - } - } -} diff --git a/versions/v19/projects/sdcorejs-angular/components/chart/src/pie-chart.component.ts b/versions/v19/projects/sdcorejs-angular/components/chart/src/pie-chart.component.ts deleted file mode 100644 index 7c6899819..000000000 --- a/versions/v19/projects/sdcorejs-angular/components/chart/src/pie-chart.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ChangeDetectionStrategy, Component, ElementRef, InputSignal, OnDestroy, OnInit, effect, input, viewChild } from '@angular/core'; -import { Chart, ChartData, ChartOptions, Plugin, registerables } from 'chart.js'; - -Chart.register(...registerables); - -@Component({ - selector: 'sd-pie-chart', - template: ` -
- -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, -}) -export class SdPieChartComponent implements OnInit, OnDestroy { - canvas = viewChild.required>('canvas'); - - data: InputSignal> = input.required>(); - options: InputSignal | undefined> = input>(); - plugins: InputSignal[]> = input[]>([]); - - chart: Chart<'pie'> | undefined; - - constructor() { - effect(() => { - const currentData = this.data(); - const currentOptions = this.options(); - - if (this.chart) { - this.chart.data = currentData; - if (currentOptions) { - this.chart.options = currentOptions; - } - this.chart.update(); - } - }); - } - - ngOnInit() { - this.#initChart(); - } - - ngOnDestroy() { - if (this.chart) { - this.chart.destroy(); - } - } - - #initChart() { - if (this.chart) { - this.chart.destroy(); - } - const ctx = this.canvas().nativeElement.getContext('2d'); - if (ctx) { - this.chart = new Chart(ctx, { - type: 'pie', - data: this.data(), - options: this.options() || {}, - plugins: this.plugins(), - }); - } - } -} diff --git a/versions/v19/projects/sdcorejs-angular/components/form-generic/sd-form-generic.md b/versions/v19/projects/sdcorejs-angular/components/form-generic/sd-form-generic.md index e288032ab..0254e79de 100644 --- a/versions/v19/projects/sdcorejs-angular/components/form-generic/sd-form-generic.md +++ b/versions/v19/projects/sdcorejs-angular/components/form-generic/sd-form-generic.md @@ -113,6 +113,25 @@ interface SdFormGenericComponentBase { `SdFormGenericGroup` is not a field — it is a titled container with its own `layout`, a nested `components` array, and required `properties.{icon, color}`. Groups do not nest: the builder hides `group` from the palette while you are editing one. +Groups support `properties.hidden`, `hiddenWhenExpression` and `visibleWhenExpression` for visibility. They have **no disabled-when option** (`disabledWhenExpression`) or required rule; configure those on individual child fields. Legacy group-level disable conditions are ignored by the renderer. + +| Group property | Type | Default | Behaviour | +| --- | --- | --- | --- | +| `properties.collapsible` | `boolean` | `false` | Allows expanding/collapsing the group in builder preview and ``, including viewed mode. Uses the same `collapsible` property as ``. | + +```ts +const group: SdFormGenericGroup = { + id: 'details', + type: 'group', + label: 'Details', + layout: { columns: '12' }, + properties: { icon: 'category', color: 'primary', collapsible: true }, + components: [/* child fields */], +}; +``` + +Groups start expanded. Collapsing changes presentation only: child controls keep their values, touched state and validation. The expanded/collapsed state belongs to the rendered section instance and is not saved to the schema. The design canvas remains available for editing group contents through Details. + `SdFormGenericValues` is a convenience alias for `SdFormGenericRadio | SdFormGenericSelect | SdFormGenericChecklist`. Their inline `values` entries are `SdFormGenericSelectionStaticItem` — `{ value: string; label: string }` (note `label`, not `display`; the `display` spelling belongs to `SdFormGenericSelectionItem`, which is what a `valuesKey` lookup returns). ### Expressions diff --git a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.html b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.html index 84c442546..9c9a4ccad 100644 --- a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.html +++ b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.html @@ -8,6 +8,10 @@
+
{{ 'core.component.form-builder.group-icon' | sdTranslate }}
diff --git a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.spec.ts b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.spec.ts new file mode 100644 index 000000000..53f7ecaa7 --- /dev/null +++ b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { I18nService } from '@sdcorejs/angular/i18n'; +import { SdFormGenericGroup } from '../../../../../models'; +import { AttributeExpression } from '../../attribute-expression/attribute-expression.component'; +import { AttributeSwitch } from '../../attribute-switch/attribute-switch.component'; +import { GroupAttribute } from './group-attribute.component'; + +describe('GroupAttribute', () => { + let fixture: ComponentFixture; + let group: SdFormGenericGroup; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [GroupAttribute, NoopAnimationsModule] }); + group = { + id: 'group', + type: 'group', + label: 'Details', + layout: { columns: '12' }, + components: [], + properties: { icon: 'category', color: 'primary' }, + }; + fixture = TestBed.createComponent(GroupAttribute); + fixture.componentRef.setInput('group', group); + fixture.detectChanges(); + }); + + it('offers visibility expressions without a disabled-when option', () => { + const i18n = TestBed.inject(I18nService); + const labels = fixture.debugElement + .queryAll(By.directive(AttributeExpression)) + .map(element => (element.componentInstance as AttributeExpression).label); + expect(labels).toEqual([i18n.t('core.component.form-builder.visible-when'), i18n.t('core.component.form-builder.hidden-when')]); + expect('disabledWhenExpression' in group.properties).toBeFalse(); + }); + + it('defaults collapsible to false and saves both choices in group properties', () => { + const optionElement = fixture.debugElement.query(By.directive(AttributeSwitch)); + expect(optionElement).not.toBeNull(); + if (!optionElement) return; + const option = optionElement.componentInstance as AttributeSwitch; + expect(option.model).toBeFalse(); + + option.modelChange.emit(true); + fixture.detectChanges(); + expect((JSON.parse(JSON.stringify(group)) as SdFormGenericGroup).properties.collapsible).toBeTrue(); + + option.modelChange.emit(false); + fixture.detectChanges(); + expect(group.properties.collapsible).toBeFalse(); + }); + + it('restores the collapsible choice from a loaded schema', () => { + fixture.componentRef.setInput('group', { ...group, properties: { ...group.properties, collapsible: true } }); + fixture.detectChanges(); + const optionElement = fixture.debugElement.query(By.directive(AttributeSwitch)); + expect(optionElement).not.toBeNull(); + if (optionElement) expect((optionElement.componentInstance as AttributeSwitch).model).toBeTrue(); + }); +}); diff --git a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.ts b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.ts index d7190d45c..3d496daf5 100644 --- a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.ts +++ b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-builder/components/group/attribute/group-attribute.component.ts @@ -5,6 +5,7 @@ import { SD_COMPONENT_ICONS, SdFormGenericComponent, SdFormGenericGroup } from ' import { BuilderService } from '../../../services'; import { AttributeExpression } from '../../attribute-expression/attribute-expression.component'; import { AttributeInput } from '../../attribute-input/attribute-input.component'; +import { AttributeSwitch } from '../../attribute-switch/attribute-switch.component'; const ICON_PRESETS = ['category', 'folder', 'inventory_2', 'group_work', 'workspaces', 'view_quilt'] as const; const COLOR_PRESETS = ['primary', 'secondary', 'success', 'warning', 'error'] as const; @@ -14,7 +15,7 @@ const COLOR_PRESETS = ['primary', 'secondary', 'success', 'warning', 'error'] as templateUrl: './group-attribute.component.html', styleUrl: './group-attribute.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [AttributeInput, AttributeExpression, SdTranslatePipe], + imports: [AttributeInput, AttributeExpression, AttributeSwitch, SdTranslatePipe], }) export class GroupAttribute { // ── constants ──────────────────────────────────────────────────────── diff --git a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-render/form-render.component.html b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-render/form-render.component.html index 17fdf59bb..0e79e21d1 100644 --- a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-render/form-render.component.html +++ b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-render/form-render.component.html @@ -6,7 +6,7 @@ @if (component.type === 'group') { @if (!!component.components.length) {
- +
diff --git a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-render/form-render.component.spec.ts b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-render/form-render.component.spec.ts index 0ca6931fc..5d4a36a1e 100644 --- a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-render/form-render.component.spec.ts +++ b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/components/form-render/form-render.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { FormGroup } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SdFormRender } from './form-render.component'; describe('SdFormRender - configuration data safety', () => { @@ -68,6 +69,108 @@ describe('SdFormRender - configuration data safety', () => { }); }); +describe('SdFormRender - group collapse', () => { + let fixture: ComponentFixture; + let form: FormGroup; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [SdFormRender, NoopAnimationsModule] }); + fixture = TestBed.createComponent(SdFormRender); + form = new FormGroup({}); + fixture.componentRef.setInput('form', form); + }); + + function render(properties: Record = {}, viewed = false) { + fixture.componentRef.setInput('viewed', viewed); + fixture.componentRef.setInput('configuration', { + components: [ + { + id: 'group', + type: 'group', + label: 'Details', + layout: { columns: '12' }, + properties: { icon: 'category', color: 'primary', ...properties }, + components: [ + { + id: 'name', + key: 'name', + type: 'textfield', + label: 'Name', + layout: { columns: '12' }, + validate: { required: true }, + properties: {}, + }, + ], + }, + ], + }); + fixture.detectChanges(); + flushMicrotasks(); + fixture.detectChanges(); + } + + for (const collapsible of [undefined, false]) { + it(`keeps the group expanded without a toggle when collapsible is ${collapsible}`, fakeAsync(() => { + render({ collapsible }); + expect(fixture.nativeElement.querySelector('.sd-section-body')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.sd-section-collapse-toggle')).toBeNull(); + })); + } + + it('collapses and expands without losing child control state, value or validation', fakeAsync(() => { + render({ collapsible: true }); + const trigger = fixture.nativeElement.querySelector('.sd-section-collapse-toggle') as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + if (!trigger) return; + const control = form.get('name')!; + expect(control.hasError('required')).toBeTrue(); + control.markAsTouched(); + + trigger.click(); + fixture.detectChanges(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(fixture.nativeElement.querySelector('.sd-section-body')).toBeNull(); + expect(form.get('name')).toBe(control); + expect(control.touched).toBeTrue(); + expect(control.hasError('required')).toBeTrue(); + control.setValue('Saved name'); + + trigger.click(); + fixture.detectChanges(); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + expect(fixture.nativeElement.querySelector('sd-input input').value).toBe('Saved name'); + expect(form.get('name')).toBe(control); + expect(control.valid).toBeTrue(); + fixture.destroy(); + })); + + it('allows collapse in viewed mode', fakeAsync(() => { + render({ collapsible: true }, true); + const trigger = fixture.nativeElement.querySelector('.sd-section-collapse-toggle') as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + if (!trigger) return; + trigger.click(); + fixture.detectChanges(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + })); + + it('ignores legacy group disable conditions and keeps visibility rules', fakeAsync(() => { + fixture.componentRef.setInput('entity', { locked: true }); + render({ + disabledWhenExpression: { + key: 'disable-group', + type: 'combinator', + combinator: '&&', + conditions: [{ key: 'locked', type: 'condition', field: 'locked', operator: 'EQUAL', value: true, dayInfo: {} }], + }, + }); + expect(form.get('name')?.enabled).toBeTrue(); + expect(fixture.nativeElement.querySelector('sd-input input').disabled).toBeFalse(); + render({ collapsible: true, hidden: true }); + expect(fixture.nativeElement.querySelector('sd-section')).toBeNull(); + })); +}); + describe('SdFormRender - initial preview render', () => { let fixture: ComponentFixture; diff --git a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/models/form-generic-component.model.ts b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/models/form-generic-component.model.ts index 0e86d0062..27598908b 100644 --- a/versions/v19/projects/sdcorejs-angular/components/form-generic/src/models/form-generic-component.model.ts +++ b/versions/v19/projects/sdcorejs-angular/components/form-generic/src/models/form-generic-component.model.ts @@ -279,6 +279,7 @@ export interface SdFormGenericGroup { properties: { icon: string; color: Color; + collapsible?: boolean; hidden?: boolean; hiddenWhenExpression?: SdFormGenericExpression; visibleWhenExpression?: SdFormGenericExpression; diff --git a/versions/v19/projects/sdcorejs-angular/components/history/sd-history.md b/versions/v19/projects/sdcorejs-angular/components/history/sd-history.md index b1d7413a3..0a9be5689 100644 --- a/versions/v19/projects/sdcorejs-angular/components/history/sd-history.md +++ b/versions/v19/projects/sdcorejs-angular/components/history/sd-history.md @@ -20,7 +20,7 @@ Vertical timeline that renders a list of history/audit events — each with titl ## When NOT to use - For a list of records the user can act on → use a table / list component - For chat/messaging → use a dedicated chat thread component -- For a chart of events over time → use `` with a time axis +- For a chart of events over time → use a chart library selected and installed by the consuming application - For a single most-recent event → just render plain text with a `` ## Inputs diff --git a/versions/v19/projects/sdcorejs-angular/components/index.ts b/versions/v19/projects/sdcorejs-angular/components/index.ts index b5f5ba3b4..c938d0c73 100644 --- a/versions/v19/projects/sdcorejs-angular/components/index.ts +++ b/versions/v19/projects/sdcorejs-angular/components/index.ts @@ -22,7 +22,6 @@ export * from '@sdcorejs/angular/components/import-excel'; export * from '@sdcorejs/angular/components/mini-editor'; export * from '@sdcorejs/angular/components/view'; export * from '@sdcorejs/angular/components/code-editor'; -export * from '@sdcorejs/angular/components/chart'; export * from '@sdcorejs/angular/components/editor'; export * from '@sdcorejs/angular/components/splitter'; export * from '@sdcorejs/angular/components/preview'; diff --git a/versions/v19/projects/sdcorejs-angular/components/modal/sd-modal.md b/versions/v19/projects/sdcorejs-angular/components/modal/sd-modal.md index 7b2f22ae5..571ac1427 100644 --- a/versions/v19/projects/sdcorejs-angular/components/modal/sd-modal.md +++ b/versions/v19/projects/sdcorejs-angular/components/modal/sd-modal.md @@ -41,7 +41,7 @@ import { SdModal } from '@sdcorejs/angular/components/modal'; | `[sdFooterLeft]` | Footer left action group. | | `[sdFooterRight]` | Footer right action group. | -Header, body and footer align to 24px horizontal insets (16px on mobile). Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. +Header and footer use a white background (`--sd-white`, default `#fff`) and compact 12px vertical / 16px horizontal padding. Dialog and bottom-sheet body content uses `padding: 12px 16px` on desktop and mobile, giving it its own top and bottom spacing within the separate body surface. Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. ## Public API @@ -101,4 +101,4 @@ The real dialog/bottom-sheet container is associated with its visible heading us ## Overlay presentation refresh -Default dialog radius is 8px and bottom-sheet radius is 12px; existing --sd-overlay-radius and --sd-overlay-bottom-sheet-radius overrides remain supported. Header/body/footer use aligned 24px horizontal insets (16px on narrow screens), 18px titles and 44px mobile action targets. Long content scrolls in the body and footer actions wrap; safe-area bottom padding and reduced-motion transitions are handled locally. Existing projected slots, width/view options and guarded dismissal remain available. +Default dialog radius is 8px and bottom-sheet radius is 12px; existing --sd-overlay-radius and --sd-overlay-bottom-sheet-radius overrides remain supported. Dialog and bottom-sheet header/footer share 12px vertical padding, 16px horizontal insets and a white background (`--sd-white`, default `#fff`). Body content aligns to the 16px inset; titles remain 18px and mobile action targets remain 44px. Long content scrolls in the body and footer actions wrap; safe-area bottom padding and reduced-motion transitions are handled locally. Existing projected slots, width/view options and guarded dismissal remain available. diff --git a/versions/v19/projects/sdcorejs-angular/components/modal/src/modal.component.scss b/versions/v19/projects/sdcorejs-angular/components/modal/src/modal.component.scss index 820b54dd8..73e50c93b 100644 --- a/versions/v19/projects/sdcorejs-angular/components/modal/src/modal.component.scss +++ b/versions/v19/projects/sdcorejs-angular/components/modal/src/modal.component.scss @@ -66,8 +66,7 @@ } .sd-modal-header { - min-height: 64px; - padding: 16px; + min-height: 56px; display: flex; align-items: center; gap: 16px; @@ -175,7 +174,6 @@ .sd-modal-footer { min-height: 56px; - padding: 16px; border-top: 1px solid var(--sd-modal-border); display: flex; align-items: center; @@ -213,7 +211,8 @@ .sd-modal-header, .sd-modal-footer { flex-shrink: 0; - padding: 20px 24px; + padding: 12px 16px; + background: var(--sd-white, #fff); } .sd-modal-title { font-size: 18px; @@ -222,17 +221,13 @@ overflow-wrap: anywhere; } .sd-modal-body { - padding: 0 24px 20px; + padding: 12px 16px; box-sizing: border-box; } @media (max-width: 600px) { .sd-modal-header { - padding: 16px; gap: 8px; } - .sd-modal-body { - padding: 0 16px 16px; - } .sd-modal-footer { padding: 12px 16px max(12px, env(safe-area-inset-bottom)); flex-wrap: wrap; diff --git a/versions/v19/projects/sdcorejs-angular/components/query-builder/sd-query-builder.md b/versions/v19/projects/sdcorejs-angular/components/query-builder/sd-query-builder.md index 092e98f86..881fbeffe 100644 --- a/versions/v19/projects/sdcorejs-angular/components/query-builder/sd-query-builder.md +++ b/versions/v19/projects/sdcorejs-angular/components/query-builder/sd-query-builder.md @@ -285,5 +285,6 @@ fields: SdQueryBuilderField[] = [ ## Accessibility - The builder container is `role="group"` (a set of controls, not a widget) so it takes no tab stop of its own — Tab goes straight to the buttons and pickers inside. -- "Click empty space to close open dropdowns" is a mouse-only affordance; **Escape** now does the same thing, so keyboard users can dismiss an open add-menu. -- The add-menu dropdown is `role="group"` and swallows Enter as well as click, so activating a button inside it never reaches the container's "close all" handler. +- The add-menu renders in a CDK connected overlay on the document body, so modal scroll regions, table cells and other ancestor overflow cannot clip its actions. It anchors to the group's `+` button, can flip above when space below is insufficient, and stays within the viewport. +- Clicking the transparent backdrop or pressing **Escape** closes the menu. Escape is consumed before it reaches the surrounding modal; only the menu closes. +- The add-menu is `role="group"` with two native buttons. Opening it focuses the first action, Tab stays within its actions, and closing it restores focus to the `+` trigger. `aria-expanded` on the trigger follows the menu state. Only one group's add-menu can be open at a time; adding a condition or group closes it. diff --git a/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder-overlay.spec.ts b/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder-overlay.spec.ts new file mode 100644 index 000000000..9a1793a27 --- /dev/null +++ b/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder-overlay.spec.ts @@ -0,0 +1,135 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { LayoutTestTheme } from '../../../testing/layout-theme.spec'; +import { SdQueryBuilder } from './query-builder.component'; +import { SdQbGroup } from './query-builder.model'; + +@Component({ + imports: [SdQueryBuilder, LayoutTestTheme], + template: ` + +
+ +
+ `, +}) +class ClippedBuilderHost { + disabled = false; +} + +describe('SdQueryBuilder add-menu overlay', () => { + let fixture: ComponentFixture; + let builder: SdQueryBuilder; + let overlays: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ClippedBuilderHost, NoopAnimationsModule] }); + fixture = TestBed.createComponent(ClippedBuilderHost); + fixture.detectChanges(); + builder = fixture.debugElement.query(By.directive(SdQueryBuilder)).componentInstance; + overlays = TestBed.inject(OverlayContainer).getContainerElement(); + }); + + function open(index = 0): HTMLButtonElement { + const trigger = fixture.nativeElement.querySelectorAll('.qb-btn-add')[index] as HTMLButtonElement; + trigger.focus(); + trigger.click(); + fixture.detectChanges(); + tick(); + return trigger; + } + + it('renders both actions outside the clipped container and keeps the lower action clickable', fakeAsync(() => { + open(); + const menu = overlays.querySelector('.qb-dropdown'); + expect(menu).not.toBeNull(); + if (!menu) return; + const actions = menu.querySelectorAll('button'); + expect(actions.length).toBe(2); + const lower = actions[1].getBoundingClientRect(); + expect(lower.height).toBeGreaterThan(0); + expect(lower.bottom).toBeGreaterThan(fixture.nativeElement.querySelector('[data-clip]').getBoundingClientRect().bottom); + expect(actions[1].contains(document.elementFromPoint(lower.x + lower.width / 2, lower.y + lower.height / 2))).toBeTrue(); + actions[1].click(); + fixture.detectChanges(); + tick(); + expect(builder.tree().children[0].kind).toBe('group'); + expect(overlays.querySelector('.qb-dropdown')).toBeNull(); + })); + + it('adds a condition to the selected nested group and allows only one open menu', fakeAsync(() => { + builder.addGroup(builder.tree()); + fixture.detectChanges(); + open(0); + builder.toggleDropdown(builder.tree().children[0] as SdQbGroup, new Event('click')); + fixture.detectChanges(); + tick(); + const menus = overlays.querySelectorAll('.qb-dropdown'); + expect(menus.length).toBe(1); + if (!menus.length) return; + (menus[0].querySelector('button') as HTMLButtonElement).click(); + fixture.detectChanges(); + expect((builder.tree().children[0] as SdQbGroup).children.length).toBe(2); + expect(builder.tree().children.length).toBe(1); + })); + + it('closes on Escape without bubbling to the modal and restores focus to the trigger', fakeAsync(() => { + const trigger = open(); + const action = overlays.querySelector('.qb-dropdown button') as HTMLButtonElement | null; + expect(action).not.toBeNull(); + if (!action) return; + expect(document.activeElement).toBe(action); + const escaped = jasmine.createSpy('document escape'); + document.addEventListener('keydown', escaped); + try { + action.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fixture.detectChanges(); + tick(); + expect(overlays.querySelector('.qb-dropdown')).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(escaped).not.toHaveBeenCalled(); + } finally { + document.removeEventListener('keydown', escaped); + } + })); + + it('closes on backdrop click and removes the overlay when disabled or destroyed', fakeAsync(() => { + open(); + const backdrop = overlays.querySelector('.cdk-overlay-backdrop') as HTMLElement | null; + expect(backdrop).not.toBeNull(); + if (!backdrop) return; + backdrop.click(); + fixture.detectChanges(); + tick(500); + expect(builder.tree().open).toBeFalse(); + expect(overlays.querySelector('.qb-dropdown')).toBeNull(); + open(); + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + tick(500); + expect(overlays.querySelector('.qb-dropdown')).toBeNull(); + expect(builder.tree().open).toBeFalse(); + fixture.componentInstance.disabled = false; + fixture.detectChanges(); + expect(overlays.querySelector('.qb-dropdown')).toBeNull(); + open(); + fixture.destroy(); + flush(); + expect(overlays.querySelector('.cdk-overlay-pane')).toBeNull(); + })); + + it('flips above the trigger near the bottom of the viewport', fakeAsync(() => { + const clip = fixture.nativeElement.querySelector('[data-clip]') as HTMLElement; + Object.assign(clip.style, { position: 'fixed', bottom: '0', margin: '0' }); + const trigger = open(); + const menu = overlays.querySelector('.qb-dropdown'); + expect(menu).not.toBeNull(); + if (!menu) return; + const rect = menu.getBoundingClientRect(); + expect(rect.bottom).toBeLessThanOrEqual(trigger.getBoundingClientRect().top); + expect(rect.top).toBeGreaterThanOrEqual(8); + })); +}); diff --git a/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.html b/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.html index 7fcbbbf57..977263a0b 100644 --- a/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.html +++ b/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.html @@ -25,26 +25,39 @@ @let _removeGroupLabel = removeGroupLabel(); + @let _addNodeLabel = addNodeLabel(); + @let _disabled = resolvedDisabled();
-
+
- - + +
- - @if (group.open) { - -
+ + + +
- } +
@if (!isRoot) { - + }
diff --git a/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss b/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss index 3025f6831..e631045ce 100644 --- a/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss +++ b/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss @@ -87,9 +87,6 @@ $node-gap: 12px; position: relative; z-index: 1; - &.is-open { - z-index: 1000; - } } // AND / OR segmented toggle — active side uses primary. @@ -167,16 +164,11 @@ $node-gap: 12px; } .qb-dropdown { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; background: #fff; border: 1px solid $border; box-shadow: 0 4px 12px rgba(16, 24, 40, 0.12); border-radius: 6px; min-width: 150px; - z-index: 100; overflow: hidden; button { diff --git a/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts b/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts index b8957417f..c90472925 100644 --- a/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts +++ b/versions/v19/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts @@ -1,4 +1,6 @@ import { NgTemplateOutlet } from '@angular/common'; +import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay'; +import { CdkTrapFocus } from '@angular/cdk/a11y'; import { ChangeDetectionStrategy, Component, @@ -70,12 +72,31 @@ const QB_EMPTY_FIELDS: SdQueryBuilderField[] = []; selector: 'sd-query-builder', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SdIcon, NgTemplateOutlet, SdOperator, SdSelect, SdInput, SdInputNumber, SdDate, SdDatetime, SdItemDefDefDirective], + imports: [ + SdIcon, + NgTemplateOutlet, + SdOperator, + SdSelect, + SdInput, + SdInputNumber, + SdDate, + SdDatetime, + SdItemDefDefDirective, + CdkConnectedOverlay, + CdkOverlayOrigin, + CdkTrapFocus, + ], templateUrl: './query-builder.component.html', styleUrl: './query-builder.component.scss', }) export class SdQueryBuilder { readonly #i18n = inject(I18nService); + protected readonly addMenuPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 }, + ]; // ------------------------------------------------------------------------- // Inputs @@ -232,6 +253,11 @@ export class SdQueryBuilder { readonly fieldIcon = sdQbFieldIcon; constructor() { + effect(() => { + if (this.resolvedDisabled() || this.isView()) { + untracked(() => this.closeAllDropdowns()); + } + }); // Inbound seeding: an EXTERNAL write to value / filters rebuilds the tree. // Our own emits are recognised via #lastEmitted and skipped (no echo loop). effect(() => { @@ -311,6 +337,13 @@ export class SdQueryBuilder { this.#bumpTree(); } + protected onAddMenuKeydown(event: KeyboardEvent): void { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + this.closeAllDropdowns(); + } + #hasOpenDropdown(node: SdQbNode): boolean { if (!sdIsQbGroup(node)) return false; return node.open === true || node.children.some(c => this.#hasOpenDropdown(c)); diff --git a/versions/v19/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md b/versions/v19/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md index 0344e62c9..e2d011c0f 100644 --- a/versions/v19/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md +++ b/versions/v19/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md @@ -37,7 +37,7 @@ import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer'; | `[sdFooterLeft]` | Footer left action group. | | `[sdFooterRight]` | Footer right action group. | -Header, body and footer align to 24px horizontal insets (16px on mobile). Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. +Header and footer use a white background (`--sd-white`, default `#fff`) and compact 12px vertical / 16px horizontal padding. Body content uses `padding: 12px 16px` on desktop and mobile, giving it its own top and bottom spacing within the separate body surface. Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. ## Public API @@ -88,7 +88,7 @@ Do not write `document.body.style.overflow` yourself while a drawer is open; the display: flex; flex-direction: column; gap: 12px; - padding: 16px; + padding: 0; // The drawer supplies the content insets. } ``` @@ -105,4 +105,4 @@ Each open creates a CDK focus trap around the drawer content. Focus moves inside Opening makes the drawer visible immediately so focus can enter before the slide/fade animation finishes. Closing delays the hidden state until that animation completes. Reduced motion removes these transitions. -The default radius is 8px, overridable through --sd-overlay-radius. Header/body/footer align at 24px horizontally (16px below 600px); the body scrolls while footer actions remain available. Close/actions are at least 44px on mobile, footer groups wrap, and reduced motion is enforced by the component itself. +The default radius is 8px, overridable through --sd-overlay-radius. Header/footer use 12px vertical padding and header/body/footer align at 16px horizontally; the body scrolls while footer actions remain available. Close/actions are at least 44px on mobile, footer groups wrap, and reduced motion is enforced by the component itself. diff --git a/versions/v19/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss b/versions/v19/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss index 1c1ea5c49..603a8d8cb 100644 --- a/versions/v19/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss +++ b/versions/v19/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss @@ -37,8 +37,7 @@ } &-header { - min-height: 64px; - padding: 16px; + min-height: 56px; display: flex; align-items: center; justify-content: space-between; @@ -131,8 +130,6 @@ &-footer { border-top: 1px solid var(--sd-side-drawer-border); - padding: 16px; - background-color: var(--sd-side-drawer-surface); display: flex; align-items: center; justify-content: space-between; @@ -197,22 +194,19 @@ .sd-side-drawer-header, .sd-side-drawer-footer { flex-shrink: 0; - padding: 20px 24px; + padding: 12px 16px; + background-color: var(--sd-white, #fff); } .sd-side-drawer-content { - padding: 0 24px 20px; + padding: 12px 16px; } @media (max-width: 600px) { .sd-side-drawer { --sd-side-drawer-inset: 8px; } .sd-side-drawer-header { - padding: 16px; gap: 8px; } - .sd-side-drawer-content { - padding: 0 16px 16px; - } .sd-side-drawer-footer { padding: 12px 16px max(12px, env(safe-area-inset-bottom)); flex-wrap: wrap; diff --git a/versions/v19/projects/sdcorejs-angular/components/tab/sd-tab.md b/versions/v19/projects/sdcorejs-angular/components/tab/sd-tab.md index 918663850..5d4a9439f 100644 --- a/versions/v19/projects/sdcorejs-angular/components/tab/sd-tab.md +++ b/versions/v19/projects/sdcorejs-angular/components/tab/sd-tab.md @@ -34,7 +34,7 @@ Declarative tab container that wraps Angular Material's `mat-tab-group` with a s | Name | Type | Default | Notes | | ------------------- | ------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `selectedIndex` | `number` (model — two-way) | `0` | Index of the active tab. Two-way bindable via `[(selectedIndex)]`. Auto-clamped to `[0, tabs.length-1]` when the tab count shrinks. | -| `variant` | `'line' \| 'pills' \| 'segmented'` | `'line'` | Visual skin. `'line'` is Material's default underline ink-bar. `'pills'` renders each tab as a rounded pill with a filled active state (no underline) — useful for nested tab groups and filter bars where the default underline competes with the parent's. `'segmented'` renders a single bordered container with flush tabs (iOS segmented-control style). | +| `variant` | `'line' \| 'pills' \| 'segmented'` | `'line'` | Visual skin. `'line'` is Material's default underline ink-bar. `'pills'` renders each tab as a rounded pill with a filled active state (no underline) — useful for nested tab groups and filter bars where the default underline competes with the parent's. `'segmented'` renders a shared container with a 4px gap between tabs (iOS segmented-control style). Hover/ripple layers follow each tab's rounded shape; active pills retain their filled color on hover. | | `color` | `Color` (`'primary' \| 'secondary' \| 'info' \| 'success' \| 'warning' \| 'error'`) | `'primary'` | Drives the active-tab + indicator + badge colors via the Core CSS vars (`--sd-`, `--sd--light`). Same palette as ``, `` — pick `'warning'` for filter bars surfacing pending items, `'success'` for confirmed flows, etc. | | `headerPosition` | `'above' \| 'below'` | `'above'` | Forwarded to `mat-tab-group.headerPosition`. | | `alignTabs` | `'start' \| 'center' \| 'end'` | `'start'` | Forwarded to `mat-tab-group`'s `[mat-align-tabs]` input. **Only takes effect when `stretchTabs` is `false`** — otherwise tabs fill the row evenly and there's nothing to align. | diff --git a/versions/v19/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss b/versions/v19/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss index 7179666c2..b2949f7ca 100644 --- a/versions/v19/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss +++ b/versions/v19/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss @@ -102,6 +102,14 @@ opacity: 0 !important; } +// Material renders hover and click ripples in a separate rectangular layer. +// Clip that layer to the tab shape without clipping the label's focus indicator. +:host(.sd-tab-group--pills) ::ng-deep .mat-mdc-tab .mat-mdc-tab-ripple, +:host(.sd-tab-group--segmented) ::ng-deep .mat-mdc-tab .mat-mdc-tab-ripple { + border-radius: inherit; + overflow: hidden; +} + // ============================================================================= // Variant: pills — rounded pill tabs, active filled with primary. // Good for nested tab groups, filter bars, light contexts. @@ -132,7 +140,7 @@ background: transparent; transition: background-color 120ms ease, color 120ms ease; - &:hover:not(.mat-mdc-tab-disabled) { + &:hover:not(.mat-mdc-tab-disabled):not(.mdc-tab--active) { background: var(--sd-tab-badge-bg); } @@ -143,12 +151,6 @@ background: var(--sd-tab-label-active-color); .mdc-tab__text-label { color: #ffffff; } } - - // why: active pill darken on hover instead of the default whitish state-layer. - // box-shadow inset > filter because filter would dim text + icons too. - &.mdc-tab--active:hover:not(.mat-mdc-tab-disabled) { - box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0.12); - } } // Inside the pill we still allow the sd-tab__label inline-flex with icon/badge/close. @@ -166,7 +168,7 @@ } // ============================================================================= -// Variant: segmented — single bordered container, tabs sit flush. +// Variant: segmented — shared container with a small gap between tabs. // iOS-style segmented control. // ============================================================================= :host(.sd-tab-group--segmented) ::ng-deep { @@ -185,7 +187,7 @@ padding: 3px; } - .mat-mdc-tab-labels { gap: 0; } + .mat-mdc-tab-labels { gap: 4px; } .mat-mdc-tab { min-width: 0; diff --git a/versions/v19/projects/sdcorejs-angular/components/table/sd-table.md b/versions/v19/projects/sdcorejs-angular/components/table/sd-table.md index eee0bf1bc..bd8134c44 100644 --- a/versions/v19/projects/sdcorejs-angular/components/table/sd-table.md +++ b/versions/v19/projects/sdcorejs-angular/components/table/sd-table.md @@ -401,9 +401,11 @@ Client-side matching sống ở `matchesColumnFilter` (`services/table-local/tab Command icons default to Material Symbols Outlined (`material-icons-outlined`). Child command menu items use the same default unless `child.fontSet` is provided. Use icon + title for child commands so menu rows align consistently. +Desktop command `children`, selection action `children` and the selection toolbar More menu use a compact CDK overlay menu: white background, 8px corners, 36px minimum rows, 18px icons and a 4px gap from the trigger. Touch/narrow-screen menu rows remain at least 44px. Menus escape clipped table cells, support arrow-key/typeahead navigation and restore trigger focus on Escape or selection. Existing child visibility, disabled state, custom HTML and callbacks remain supported; no consumer configuration changes are required. + Desktop row commands keep their touch targets within each 24px button so adjacent actions and menu triggers remain independently clickable. Mobile action controls retain their existing sizing. -Disabled desktop row-command icons inherit the button's disabled color from the Material theme, making them visibly muted instead of retaining the active icon color. Child menu items retain Material's disabled opacity. +Disabled desktop row-command icons inherit the button's disabled color from the Material theme, making them visibly muted instead of retaining the active icon color. Disabled child menu items are muted and cannot be activated. ```ts command: { diff --git a/versions/v19/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss b/versions/v19/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss new file mode 100644 index 000000000..0a0a9bbc5 --- /dev/null +++ b/versions/v19/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss @@ -0,0 +1,67 @@ +.sd-table-action-menu { + display: flex; + flex-direction: column; + box-sizing: border-box; + min-width: 160px; + max-width: min(320px, calc(100vw - 16px)); + max-height: min(360px, calc(100dvh - 16px)); + overflow: auto; + padding: 4px; + border: 1px solid var(--sd-border, #dde0e5); + border-radius: 8px; + background: var(--sd-white, #fff); + color: var(--sd-text, #1a1b1f); + box-shadow: + 0 4px 12px rgba(15, 23, 42, 0.14), + 0 1px 3px rgba(15, 23, 42, 0.1); + + [cdkMenuItem] { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 36px; + padding: 8px 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font-family: inherit; + font-size: 14px; + line-height: 20px; + text-align: start; + white-space: normal; + overflow-wrap: anywhere; + cursor: pointer; + + &:hover:not(:disabled), + &:focus-visible { + background: var(--sd-black100, #f3f3f3); + } + + &:focus-visible { + outline: 2px solid var(--sd-primary, #005cbb); + outline-offset: -2px; + } + + &:disabled { + opacity: 0.45; + cursor: default; + } + } + + .sd-table-action-menu__icon { + flex: 0 0 18px; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } +} + +@media (max-width: 600px), (pointer: coarse) { + .sd-table-action-menu [cdkMenuItem] { + min-height: 44px; + } +} diff --git a/versions/v19/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts b/versions/v19/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts new file mode 100644 index 000000000..7cfd448fc --- /dev/null +++ b/versions/v19/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts @@ -0,0 +1,67 @@ +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { Directive, ElementRef, afterRenderEffect, inject, DestroyRef, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { SdButton } from '@sdcorejs/angular/components/button'; + +export const TABLE_ACTION_MENU_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 }, + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, +]; + +// why: CDK gắn trên host sd-button, nhưng focus/ARIA và Enter/Space phải thuộc nút HTML bên trong. +@Directive({ + selector: 'sd-button[sdTableMenuButton]', + standalone: true, + hostDirectives: [{ directive: CdkMenuTrigger, inputs: ['cdkMenuTriggerFor: sdTableMenuButton'] }], +}) +export class SdTableMenuButtonDirective { + readonly #host = inject>(ElementRef).nativeElement; + readonly #trigger = inject(CdkMenuTrigger); + readonly #sdButton = inject(SdButton); + readonly #opened = signal(false); + #button?: HTMLButtonElement; + + constructor() { + this.#trigger.menuPosition = TABLE_ACTION_MENU_POSITIONS; + const destroyRef = inject(DestroyRef); + this.#trigger.opened.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => this.#opened.set(true)); + this.#trigger.closed.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => { + this.#opened.set(false); + if (this.#button?.isConnected && this.#trigger.getMenu()?.nativeElement.contains(this.#host.ownerDocument.activeElement)) { + this.#button.focus(); + } + }); + afterRenderEffect(() => { + this.#sdButton.type(); + this.#opened(); + this.#syncButton(); + }); + destroyRef.onDestroy(() => this.#button?.removeEventListener('keydown', this.#nativeActivation, true)); + } + + readonly #nativeActivation = (event: KeyboardEvent): void => { + // why: để nút native tự phát click đúng một lần, không cho CDK toggle thêm ở keydown của host. + if (event.key === 'Enter' || event.key === ' ') event.stopPropagation(); + }; + + #syncButton(): void { + const button = this.#host.querySelector('button') ?? undefined; + if (button !== this.#button) { + this.#button?.removeEventListener('keydown', this.#nativeActivation, true); + this.#button = button; + button?.addEventListener('keydown', this.#nativeActivation, true); + } + button?.setAttribute('aria-haspopup', 'menu'); + button?.setAttribute('aria-expanded', String(this.#trigger.isOpen())); + const menuId = this.#trigger.getMenu()?.nativeElement.id; + if (menuId) button?.setAttribute('aria-controls', menuId); + else button?.removeAttribute('aria-controls'); + this.#host.removeAttribute('role'); + this.#host.removeAttribute('aria-haspopup'); + this.#host.removeAttribute('aria-expanded'); + this.#host.removeAttribute('aria-controls'); + } +} diff --git a/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html b/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html index 38cfbc7b3..9ed43877f 100644 --- a/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html +++ b/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html @@ -24,7 +24,9 @@ - - + +
@for (childCommand of command.children; track childCommand.key) { @let _childMeta = _item | command: childCommand; } - - +
+
} } } diff --git a/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss b/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss index c90841d55..fe78277aa 100644 --- a/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss +++ b/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss @@ -1,5 +1,6 @@ @use '../../../../../assets/scss/core/color.scss' as color; @use 'sass:map'; +@use '../action-menu/action-menu'; :host { display: flex; @@ -42,20 +43,8 @@ button[mat-icon-button]:disabled sd-icon.c-icon { width: 100%; } -// why: `margin-right: 0 !important` ở đây từng để chặn margin Material gắn vào `.mat-icon` trong -// mat-menu. sd-icon đã trung hoà ở component nên bỏ được; khoảng cách với nhãn là `gap` của -// .sd-command-menu-item__content. -.sd-command-menu-item__icon { - color: map.get(color.$color_map, 'text-secondary') !important; - width: 24px; - height: 24px; - font-size: 18px; - line-height: 24px !important; - flex: 0 0 24px; -} - .sd-command-menu-item__title { min-width: 0; line-height: 20px; - white-space: nowrap; + white-space: normal; } diff --git a/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts b/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts index 79a9b04b1..03e5eff62 100644 --- a/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts +++ b/versions/v19/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts @@ -54,7 +54,7 @@ describe('DesktopCommand', () => { fixture.detectChanges(); await fixture.whenStable(); - const menu = document.body.querySelector('.mat-mdc-menu-panel') as HTMLElement; + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; const content = menu.querySelector('.sd-command-menu-item__content') as HTMLElement; const icon = menu.querySelector('mat-icon') as HTMLElement; const title = menu.querySelector('.sd-command-menu-item__title') as HTMLElement; @@ -62,6 +62,71 @@ describe('DesktopCommand', () => { expect(content).not.toBeNull(); expect(icon.classList).toContain('material-icons-outlined'); expect(title.textContent?.trim()).toBe('Edit'); + expect(menu.classList).toContain('sd-table-action-menu'); + expect(menu.querySelector('button')!.getBoundingClientRect().height).toBeLessThanOrEqual(36); + }); + + it('keeps hidden/disabled child rules and restores the command trigger after Escape', async () => { + const clicked = jasmine.createSpy('clicked'); + fixture.componentInstance.commands = [ + { + title: 'More', + children: [ + { title: 'Hidden', hidden: async () => true, click: clicked }, + { title: 'Disabled', disabled: true, click: clicked }, + { title: 'Allowed', click: clicked }, + ], + }, + ]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector('button') as HTMLButtonElement; + trigger.focus(); + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40, bubbles: true })); + fixture.detectChanges(); + await fixture.whenStable(); + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; + expect(menu.textContent).not.toContain('Hidden'); + const items = Array.from(menu.querySelectorAll('button')); + items[0].click(); + expect(clicked).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(items[1]); + const escape = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }); + items[1].dispatchEvent(escape); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('renders outside a clipped row and invokes the child once with its row data', async () => { + const clicked = jasmine.createSpy('clicked'); + Object.assign(fixture.nativeElement.style, { + position: 'fixed', + top: '32px', + left: '32px', + width: '80px', + height: '24px', + overflow: 'hidden', + }); + fixture.componentInstance.commands = [{ title: 'More', children: [{ title: 'Run', click: clicked }] }]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + await fixture.whenStable(); + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; + expect(fixture.nativeElement.contains(menu)).toBeFalse(); + const item = menu.querySelector('button')!; + const bounds = item.getBoundingClientRect(); + expect(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)?.closest('button')).toBe(item); + item.click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(clicked).toHaveBeenCalledOnceWith(fixture.componentInstance.item.data); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); }); // why: hai nút command từng mang aria-hidden="true" — chúng là - } -
+ [sdTableMenuButton]="childrenMenu" /> + +
+ @for (child of action.children; track $index) { + + } +
+
} } @if (_overflow.length) { @@ -55,44 +65,55 @@ class="sd-selection-more" size="sm" prefixIcon="more_horiz" - [title]="moreLabel()" + [title]="_moreLabel" color="secondary" type="light" [autoId]="_autoId ? _autoId + '-more-actions' : null" - [matMenuTriggerFor]="moreMenu" /> - - @for (entry of _overflow; track entry.index) { - @let action = entry.action; - @if (action.variant === 'normal') { - - } @else { -
-
{{ action.title }}
- @for (child of action.children; track $index) { - - } -
+ [sdTableMenuButton]="moreMenu" /> + +
+ @for (entry of _overflow; track entry.index) { + @let action = entry.action; + @if (action.variant === 'normal') { + + } @else { +
+
{{ action.title }}
+ @for (child of action.children; track $index) { + + } +
+ } } - } - +
+
} + } @else { +
+ +
+ } } @else {
+ [fontSet]="_iconFontSet" + [size]="_structured ? '20px' : undefined"> } } - {{ node.title }} + {{ node.title }} - @if (showPin()) { + @if (_showPin) { - + +
- - @if (group.open) { - -
+ + + +
- } +
@if (!isRoot) { - + }
diff --git a/versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss b/versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss index 3025f6831..e631045ce 100644 --- a/versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss +++ b/versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss @@ -87,9 +87,6 @@ $node-gap: 12px; position: relative; z-index: 1; - &.is-open { - z-index: 1000; - } } // AND / OR segmented toggle — active side uses primary. @@ -167,16 +164,11 @@ $node-gap: 12px; } .qb-dropdown { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; background: #fff; border: 1px solid $border; box-shadow: 0 4px 12px rgba(16, 24, 40, 0.12); border-radius: 6px; min-width: 150px; - z-index: 100; overflow: hidden; button { diff --git a/versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts b/versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts index b8957417f..c90472925 100644 --- a/versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts +++ b/versions/v20/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts @@ -1,4 +1,6 @@ import { NgTemplateOutlet } from '@angular/common'; +import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay'; +import { CdkTrapFocus } from '@angular/cdk/a11y'; import { ChangeDetectionStrategy, Component, @@ -70,12 +72,31 @@ const QB_EMPTY_FIELDS: SdQueryBuilderField[] = []; selector: 'sd-query-builder', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SdIcon, NgTemplateOutlet, SdOperator, SdSelect, SdInput, SdInputNumber, SdDate, SdDatetime, SdItemDefDefDirective], + imports: [ + SdIcon, + NgTemplateOutlet, + SdOperator, + SdSelect, + SdInput, + SdInputNumber, + SdDate, + SdDatetime, + SdItemDefDefDirective, + CdkConnectedOverlay, + CdkOverlayOrigin, + CdkTrapFocus, + ], templateUrl: './query-builder.component.html', styleUrl: './query-builder.component.scss', }) export class SdQueryBuilder { readonly #i18n = inject(I18nService); + protected readonly addMenuPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 }, + ]; // ------------------------------------------------------------------------- // Inputs @@ -232,6 +253,11 @@ export class SdQueryBuilder { readonly fieldIcon = sdQbFieldIcon; constructor() { + effect(() => { + if (this.resolvedDisabled() || this.isView()) { + untracked(() => this.closeAllDropdowns()); + } + }); // Inbound seeding: an EXTERNAL write to value / filters rebuilds the tree. // Our own emits are recognised via #lastEmitted and skipped (no echo loop). effect(() => { @@ -311,6 +337,13 @@ export class SdQueryBuilder { this.#bumpTree(); } + protected onAddMenuKeydown(event: KeyboardEvent): void { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + this.closeAllDropdowns(); + } + #hasOpenDropdown(node: SdQbNode): boolean { if (!sdIsQbGroup(node)) return false; return node.open === true || node.children.some(c => this.#hasOpenDropdown(c)); diff --git a/versions/v20/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md b/versions/v20/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md index 0344e62c9..e2d011c0f 100644 --- a/versions/v20/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md +++ b/versions/v20/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md @@ -37,7 +37,7 @@ import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer'; | `[sdFooterLeft]` | Footer left action group. | | `[sdFooterRight]` | Footer right action group. | -Header, body and footer align to 24px horizontal insets (16px on mobile). Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. +Header and footer use a white background (`--sd-white`, default `#fff`) and compact 12px vertical / 16px horizontal padding. Body content uses `padding: 12px 16px` on desktop and mobile, giving it its own top and bottom spacing within the separate body surface. Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. ## Public API @@ -88,7 +88,7 @@ Do not write `document.body.style.overflow` yourself while a drawer is open; the display: flex; flex-direction: column; gap: 12px; - padding: 16px; + padding: 0; // The drawer supplies the content insets. } ``` @@ -105,4 +105,4 @@ Each open creates a CDK focus trap around the drawer content. Focus moves inside Opening makes the drawer visible immediately so focus can enter before the slide/fade animation finishes. Closing delays the hidden state until that animation completes. Reduced motion removes these transitions. -The default radius is 8px, overridable through --sd-overlay-radius. Header/body/footer align at 24px horizontally (16px below 600px); the body scrolls while footer actions remain available. Close/actions are at least 44px on mobile, footer groups wrap, and reduced motion is enforced by the component itself. +The default radius is 8px, overridable through --sd-overlay-radius. Header/footer use 12px vertical padding and header/body/footer align at 16px horizontally; the body scrolls while footer actions remain available. Close/actions are at least 44px on mobile, footer groups wrap, and reduced motion is enforced by the component itself. diff --git a/versions/v20/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss b/versions/v20/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss index 1c1ea5c49..603a8d8cb 100644 --- a/versions/v20/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss +++ b/versions/v20/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss @@ -37,8 +37,7 @@ } &-header { - min-height: 64px; - padding: 16px; + min-height: 56px; display: flex; align-items: center; justify-content: space-between; @@ -131,8 +130,6 @@ &-footer { border-top: 1px solid var(--sd-side-drawer-border); - padding: 16px; - background-color: var(--sd-side-drawer-surface); display: flex; align-items: center; justify-content: space-between; @@ -197,22 +194,19 @@ .sd-side-drawer-header, .sd-side-drawer-footer { flex-shrink: 0; - padding: 20px 24px; + padding: 12px 16px; + background-color: var(--sd-white, #fff); } .sd-side-drawer-content { - padding: 0 24px 20px; + padding: 12px 16px; } @media (max-width: 600px) { .sd-side-drawer { --sd-side-drawer-inset: 8px; } .sd-side-drawer-header { - padding: 16px; gap: 8px; } - .sd-side-drawer-content { - padding: 0 16px 16px; - } .sd-side-drawer-footer { padding: 12px 16px max(12px, env(safe-area-inset-bottom)); flex-wrap: wrap; diff --git a/versions/v20/projects/sdcorejs-angular/components/tab/sd-tab.md b/versions/v20/projects/sdcorejs-angular/components/tab/sd-tab.md index 918663850..5d4a9439f 100644 --- a/versions/v20/projects/sdcorejs-angular/components/tab/sd-tab.md +++ b/versions/v20/projects/sdcorejs-angular/components/tab/sd-tab.md @@ -34,7 +34,7 @@ Declarative tab container that wraps Angular Material's `mat-tab-group` with a s | Name | Type | Default | Notes | | ------------------- | ------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `selectedIndex` | `number` (model — two-way) | `0` | Index of the active tab. Two-way bindable via `[(selectedIndex)]`. Auto-clamped to `[0, tabs.length-1]` when the tab count shrinks. | -| `variant` | `'line' \| 'pills' \| 'segmented'` | `'line'` | Visual skin. `'line'` is Material's default underline ink-bar. `'pills'` renders each tab as a rounded pill with a filled active state (no underline) — useful for nested tab groups and filter bars where the default underline competes with the parent's. `'segmented'` renders a single bordered container with flush tabs (iOS segmented-control style). | +| `variant` | `'line' \| 'pills' \| 'segmented'` | `'line'` | Visual skin. `'line'` is Material's default underline ink-bar. `'pills'` renders each tab as a rounded pill with a filled active state (no underline) — useful for nested tab groups and filter bars where the default underline competes with the parent's. `'segmented'` renders a shared container with a 4px gap between tabs (iOS segmented-control style). Hover/ripple layers follow each tab's rounded shape; active pills retain their filled color on hover. | | `color` | `Color` (`'primary' \| 'secondary' \| 'info' \| 'success' \| 'warning' \| 'error'`) | `'primary'` | Drives the active-tab + indicator + badge colors via the Core CSS vars (`--sd-`, `--sd--light`). Same palette as ``, `` — pick `'warning'` for filter bars surfacing pending items, `'success'` for confirmed flows, etc. | | `headerPosition` | `'above' \| 'below'` | `'above'` | Forwarded to `mat-tab-group.headerPosition`. | | `alignTabs` | `'start' \| 'center' \| 'end'` | `'start'` | Forwarded to `mat-tab-group`'s `[mat-align-tabs]` input. **Only takes effect when `stretchTabs` is `false`** — otherwise tabs fill the row evenly and there's nothing to align. | diff --git a/versions/v20/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss b/versions/v20/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss index 7179666c2..b2949f7ca 100644 --- a/versions/v20/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss +++ b/versions/v20/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss @@ -102,6 +102,14 @@ opacity: 0 !important; } +// Material renders hover and click ripples in a separate rectangular layer. +// Clip that layer to the tab shape without clipping the label's focus indicator. +:host(.sd-tab-group--pills) ::ng-deep .mat-mdc-tab .mat-mdc-tab-ripple, +:host(.sd-tab-group--segmented) ::ng-deep .mat-mdc-tab .mat-mdc-tab-ripple { + border-radius: inherit; + overflow: hidden; +} + // ============================================================================= // Variant: pills — rounded pill tabs, active filled with primary. // Good for nested tab groups, filter bars, light contexts. @@ -132,7 +140,7 @@ background: transparent; transition: background-color 120ms ease, color 120ms ease; - &:hover:not(.mat-mdc-tab-disabled) { + &:hover:not(.mat-mdc-tab-disabled):not(.mdc-tab--active) { background: var(--sd-tab-badge-bg); } @@ -143,12 +151,6 @@ background: var(--sd-tab-label-active-color); .mdc-tab__text-label { color: #ffffff; } } - - // why: active pill darken on hover instead of the default whitish state-layer. - // box-shadow inset > filter because filter would dim text + icons too. - &.mdc-tab--active:hover:not(.mat-mdc-tab-disabled) { - box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0.12); - } } // Inside the pill we still allow the sd-tab__label inline-flex with icon/badge/close. @@ -166,7 +168,7 @@ } // ============================================================================= -// Variant: segmented — single bordered container, tabs sit flush. +// Variant: segmented — shared container with a small gap between tabs. // iOS-style segmented control. // ============================================================================= :host(.sd-tab-group--segmented) ::ng-deep { @@ -185,7 +187,7 @@ padding: 3px; } - .mat-mdc-tab-labels { gap: 0; } + .mat-mdc-tab-labels { gap: 4px; } .mat-mdc-tab { min-width: 0; diff --git a/versions/v20/projects/sdcorejs-angular/components/table/sd-table.md b/versions/v20/projects/sdcorejs-angular/components/table/sd-table.md index eee0bf1bc..bd8134c44 100644 --- a/versions/v20/projects/sdcorejs-angular/components/table/sd-table.md +++ b/versions/v20/projects/sdcorejs-angular/components/table/sd-table.md @@ -401,9 +401,11 @@ Client-side matching sống ở `matchesColumnFilter` (`services/table-local/tab Command icons default to Material Symbols Outlined (`material-icons-outlined`). Child command menu items use the same default unless `child.fontSet` is provided. Use icon + title for child commands so menu rows align consistently. +Desktop command `children`, selection action `children` and the selection toolbar More menu use a compact CDK overlay menu: white background, 8px corners, 36px minimum rows, 18px icons and a 4px gap from the trigger. Touch/narrow-screen menu rows remain at least 44px. Menus escape clipped table cells, support arrow-key/typeahead navigation and restore trigger focus on Escape or selection. Existing child visibility, disabled state, custom HTML and callbacks remain supported; no consumer configuration changes are required. + Desktop row commands keep their touch targets within each 24px button so adjacent actions and menu triggers remain independently clickable. Mobile action controls retain their existing sizing. -Disabled desktop row-command icons inherit the button's disabled color from the Material theme, making them visibly muted instead of retaining the active icon color. Child menu items retain Material's disabled opacity. +Disabled desktop row-command icons inherit the button's disabled color from the Material theme, making them visibly muted instead of retaining the active icon color. Disabled child menu items are muted and cannot be activated. ```ts command: { diff --git a/versions/v20/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss b/versions/v20/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss new file mode 100644 index 000000000..0a0a9bbc5 --- /dev/null +++ b/versions/v20/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss @@ -0,0 +1,67 @@ +.sd-table-action-menu { + display: flex; + flex-direction: column; + box-sizing: border-box; + min-width: 160px; + max-width: min(320px, calc(100vw - 16px)); + max-height: min(360px, calc(100dvh - 16px)); + overflow: auto; + padding: 4px; + border: 1px solid var(--sd-border, #dde0e5); + border-radius: 8px; + background: var(--sd-white, #fff); + color: var(--sd-text, #1a1b1f); + box-shadow: + 0 4px 12px rgba(15, 23, 42, 0.14), + 0 1px 3px rgba(15, 23, 42, 0.1); + + [cdkMenuItem] { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 36px; + padding: 8px 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font-family: inherit; + font-size: 14px; + line-height: 20px; + text-align: start; + white-space: normal; + overflow-wrap: anywhere; + cursor: pointer; + + &:hover:not(:disabled), + &:focus-visible { + background: var(--sd-black100, #f3f3f3); + } + + &:focus-visible { + outline: 2px solid var(--sd-primary, #005cbb); + outline-offset: -2px; + } + + &:disabled { + opacity: 0.45; + cursor: default; + } + } + + .sd-table-action-menu__icon { + flex: 0 0 18px; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } +} + +@media (max-width: 600px), (pointer: coarse) { + .sd-table-action-menu [cdkMenuItem] { + min-height: 44px; + } +} diff --git a/versions/v20/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts b/versions/v20/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts new file mode 100644 index 000000000..7cfd448fc --- /dev/null +++ b/versions/v20/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts @@ -0,0 +1,67 @@ +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { Directive, ElementRef, afterRenderEffect, inject, DestroyRef, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { SdButton } from '@sdcorejs/angular/components/button'; + +export const TABLE_ACTION_MENU_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 }, + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, +]; + +// why: CDK gắn trên host sd-button, nhưng focus/ARIA và Enter/Space phải thuộc nút HTML bên trong. +@Directive({ + selector: 'sd-button[sdTableMenuButton]', + standalone: true, + hostDirectives: [{ directive: CdkMenuTrigger, inputs: ['cdkMenuTriggerFor: sdTableMenuButton'] }], +}) +export class SdTableMenuButtonDirective { + readonly #host = inject>(ElementRef).nativeElement; + readonly #trigger = inject(CdkMenuTrigger); + readonly #sdButton = inject(SdButton); + readonly #opened = signal(false); + #button?: HTMLButtonElement; + + constructor() { + this.#trigger.menuPosition = TABLE_ACTION_MENU_POSITIONS; + const destroyRef = inject(DestroyRef); + this.#trigger.opened.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => this.#opened.set(true)); + this.#trigger.closed.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => { + this.#opened.set(false); + if (this.#button?.isConnected && this.#trigger.getMenu()?.nativeElement.contains(this.#host.ownerDocument.activeElement)) { + this.#button.focus(); + } + }); + afterRenderEffect(() => { + this.#sdButton.type(); + this.#opened(); + this.#syncButton(); + }); + destroyRef.onDestroy(() => this.#button?.removeEventListener('keydown', this.#nativeActivation, true)); + } + + readonly #nativeActivation = (event: KeyboardEvent): void => { + // why: để nút native tự phát click đúng một lần, không cho CDK toggle thêm ở keydown của host. + if (event.key === 'Enter' || event.key === ' ') event.stopPropagation(); + }; + + #syncButton(): void { + const button = this.#host.querySelector('button') ?? undefined; + if (button !== this.#button) { + this.#button?.removeEventListener('keydown', this.#nativeActivation, true); + this.#button = button; + button?.addEventListener('keydown', this.#nativeActivation, true); + } + button?.setAttribute('aria-haspopup', 'menu'); + button?.setAttribute('aria-expanded', String(this.#trigger.isOpen())); + const menuId = this.#trigger.getMenu()?.nativeElement.id; + if (menuId) button?.setAttribute('aria-controls', menuId); + else button?.removeAttribute('aria-controls'); + this.#host.removeAttribute('role'); + this.#host.removeAttribute('aria-haspopup'); + this.#host.removeAttribute('aria-expanded'); + this.#host.removeAttribute('aria-controls'); + } +} diff --git a/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html b/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html index 38cfbc7b3..9ed43877f 100644 --- a/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html +++ b/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html @@ -24,7 +24,9 @@ - - + +
@for (childCommand of command.children; track childCommand.key) { @let _childMeta = _item | command: childCommand; } - - +
+
} } } diff --git a/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss b/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss index c90841d55..fe78277aa 100644 --- a/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss +++ b/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss @@ -1,5 +1,6 @@ @use '../../../../../assets/scss/core/color.scss' as color; @use 'sass:map'; +@use '../action-menu/action-menu'; :host { display: flex; @@ -42,20 +43,8 @@ button[mat-icon-button]:disabled sd-icon.c-icon { width: 100%; } -// why: `margin-right: 0 !important` ở đây từng để chặn margin Material gắn vào `.mat-icon` trong -// mat-menu. sd-icon đã trung hoà ở component nên bỏ được; khoảng cách với nhãn là `gap` của -// .sd-command-menu-item__content. -.sd-command-menu-item__icon { - color: map.get(color.$color_map, 'text-secondary') !important; - width: 24px; - height: 24px; - font-size: 18px; - line-height: 24px !important; - flex: 0 0 24px; -} - .sd-command-menu-item__title { min-width: 0; line-height: 20px; - white-space: nowrap; + white-space: normal; } diff --git a/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts b/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts index 79a9b04b1..03e5eff62 100644 --- a/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts +++ b/versions/v20/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts @@ -54,7 +54,7 @@ describe('DesktopCommand', () => { fixture.detectChanges(); await fixture.whenStable(); - const menu = document.body.querySelector('.mat-mdc-menu-panel') as HTMLElement; + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; const content = menu.querySelector('.sd-command-menu-item__content') as HTMLElement; const icon = menu.querySelector('mat-icon') as HTMLElement; const title = menu.querySelector('.sd-command-menu-item__title') as HTMLElement; @@ -62,6 +62,71 @@ describe('DesktopCommand', () => { expect(content).not.toBeNull(); expect(icon.classList).toContain('material-icons-outlined'); expect(title.textContent?.trim()).toBe('Edit'); + expect(menu.classList).toContain('sd-table-action-menu'); + expect(menu.querySelector('button')!.getBoundingClientRect().height).toBeLessThanOrEqual(36); + }); + + it('keeps hidden/disabled child rules and restores the command trigger after Escape', async () => { + const clicked = jasmine.createSpy('clicked'); + fixture.componentInstance.commands = [ + { + title: 'More', + children: [ + { title: 'Hidden', hidden: async () => true, click: clicked }, + { title: 'Disabled', disabled: true, click: clicked }, + { title: 'Allowed', click: clicked }, + ], + }, + ]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector('button') as HTMLButtonElement; + trigger.focus(); + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40, bubbles: true })); + fixture.detectChanges(); + await fixture.whenStable(); + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; + expect(menu.textContent).not.toContain('Hidden'); + const items = Array.from(menu.querySelectorAll('button')); + items[0].click(); + expect(clicked).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(items[1]); + const escape = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }); + items[1].dispatchEvent(escape); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('renders outside a clipped row and invokes the child once with its row data', async () => { + const clicked = jasmine.createSpy('clicked'); + Object.assign(fixture.nativeElement.style, { + position: 'fixed', + top: '32px', + left: '32px', + width: '80px', + height: '24px', + overflow: 'hidden', + }); + fixture.componentInstance.commands = [{ title: 'More', children: [{ title: 'Run', click: clicked }] }]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + await fixture.whenStable(); + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; + expect(fixture.nativeElement.contains(menu)).toBeFalse(); + const item = menu.querySelector('button')!; + const bounds = item.getBoundingClientRect(); + expect(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)?.closest('button')).toBe(item); + item.click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(clicked).toHaveBeenCalledOnceWith(fixture.componentInstance.item.data); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); }); // why: hai nút command từng mang aria-hidden="true" — chúng là - } -
+ [sdTableMenuButton]="childrenMenu" /> + +
+ @for (child of action.children; track $index) { + + } +
+
} } @if (_overflow.length) { @@ -55,44 +65,55 @@ class="sd-selection-more" size="sm" prefixIcon="more_horiz" - [title]="moreLabel()" + [title]="_moreLabel" color="secondary" type="light" [autoId]="_autoId ? _autoId + '-more-actions' : null" - [matMenuTriggerFor]="moreMenu" /> - - @for (entry of _overflow; track entry.index) { - @let action = entry.action; - @if (action.variant === 'normal') { - - } @else { -
-
{{ action.title }}
- @for (child of action.children; track $index) { - - } -
+ [sdTableMenuButton]="moreMenu" /> + +
+ @for (entry of _overflow; track entry.index) { + @let action = entry.action; + @if (action.variant === 'normal') { + + } @else { +
+
{{ action.title }}
+ @for (child of action.children; track $index) { + + } +
+ } } - } - +
+
} + } @else { +
+ +
+ } } @else {
+ [fontSet]="_iconFontSet" + [size]="_structured ? '20px' : undefined"> } } - {{ node.title }} + {{ node.title }} - @if (showPin()) { + @if (_showPin) { - + +
- - @if (group.open) { - -
+ + + +
- } +
@if (!isRoot) { - + }
diff --git a/versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss b/versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss index 3025f6831..e631045ce 100644 --- a/versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss +++ b/versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss @@ -87,9 +87,6 @@ $node-gap: 12px; position: relative; z-index: 1; - &.is-open { - z-index: 1000; - } } // AND / OR segmented toggle — active side uses primary. @@ -167,16 +164,11 @@ $node-gap: 12px; } .qb-dropdown { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; background: #fff; border: 1px solid $border; box-shadow: 0 4px 12px rgba(16, 24, 40, 0.12); border-radius: 6px; min-width: 150px; - z-index: 100; overflow: hidden; button { diff --git a/versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts b/versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts index b8957417f..c90472925 100644 --- a/versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts +++ b/versions/v21/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts @@ -1,4 +1,6 @@ import { NgTemplateOutlet } from '@angular/common'; +import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay'; +import { CdkTrapFocus } from '@angular/cdk/a11y'; import { ChangeDetectionStrategy, Component, @@ -70,12 +72,31 @@ const QB_EMPTY_FIELDS: SdQueryBuilderField[] = []; selector: 'sd-query-builder', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SdIcon, NgTemplateOutlet, SdOperator, SdSelect, SdInput, SdInputNumber, SdDate, SdDatetime, SdItemDefDefDirective], + imports: [ + SdIcon, + NgTemplateOutlet, + SdOperator, + SdSelect, + SdInput, + SdInputNumber, + SdDate, + SdDatetime, + SdItemDefDefDirective, + CdkConnectedOverlay, + CdkOverlayOrigin, + CdkTrapFocus, + ], templateUrl: './query-builder.component.html', styleUrl: './query-builder.component.scss', }) export class SdQueryBuilder { readonly #i18n = inject(I18nService); + protected readonly addMenuPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 }, + ]; // ------------------------------------------------------------------------- // Inputs @@ -232,6 +253,11 @@ export class SdQueryBuilder { readonly fieldIcon = sdQbFieldIcon; constructor() { + effect(() => { + if (this.resolvedDisabled() || this.isView()) { + untracked(() => this.closeAllDropdowns()); + } + }); // Inbound seeding: an EXTERNAL write to value / filters rebuilds the tree. // Our own emits are recognised via #lastEmitted and skipped (no echo loop). effect(() => { @@ -311,6 +337,13 @@ export class SdQueryBuilder { this.#bumpTree(); } + protected onAddMenuKeydown(event: KeyboardEvent): void { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + this.closeAllDropdowns(); + } + #hasOpenDropdown(node: SdQbNode): boolean { if (!sdIsQbGroup(node)) return false; return node.open === true || node.children.some(c => this.#hasOpenDropdown(c)); diff --git a/versions/v21/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md b/versions/v21/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md index 0344e62c9..e2d011c0f 100644 --- a/versions/v21/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md +++ b/versions/v21/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md @@ -37,7 +37,7 @@ import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer'; | `[sdFooterLeft]` | Footer left action group. | | `[sdFooterRight]` | Footer right action group. | -Header, body and footer align to 24px horizontal insets (16px on mobile). Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. +Header and footer use a white background (`--sd-white`, default `#fff`) and compact 12px vertical / 16px horizontal padding. Body content uses `padding: 12px 16px` on desktop and mobile, giving it its own top and bottom spacing within the separate body surface. Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. ## Public API @@ -88,7 +88,7 @@ Do not write `document.body.style.overflow` yourself while a drawer is open; the display: flex; flex-direction: column; gap: 12px; - padding: 16px; + padding: 0; // The drawer supplies the content insets. } ``` @@ -105,4 +105,4 @@ Each open creates a CDK focus trap around the drawer content. Focus moves inside Opening makes the drawer visible immediately so focus can enter before the slide/fade animation finishes. Closing delays the hidden state until that animation completes. Reduced motion removes these transitions. -The default radius is 8px, overridable through --sd-overlay-radius. Header/body/footer align at 24px horizontally (16px below 600px); the body scrolls while footer actions remain available. Close/actions are at least 44px on mobile, footer groups wrap, and reduced motion is enforced by the component itself. +The default radius is 8px, overridable through --sd-overlay-radius. Header/footer use 12px vertical padding and header/body/footer align at 16px horizontally; the body scrolls while footer actions remain available. Close/actions are at least 44px on mobile, footer groups wrap, and reduced motion is enforced by the component itself. diff --git a/versions/v21/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss b/versions/v21/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss index 1c1ea5c49..603a8d8cb 100644 --- a/versions/v21/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss +++ b/versions/v21/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss @@ -37,8 +37,7 @@ } &-header { - min-height: 64px; - padding: 16px; + min-height: 56px; display: flex; align-items: center; justify-content: space-between; @@ -131,8 +130,6 @@ &-footer { border-top: 1px solid var(--sd-side-drawer-border); - padding: 16px; - background-color: var(--sd-side-drawer-surface); display: flex; align-items: center; justify-content: space-between; @@ -197,22 +194,19 @@ .sd-side-drawer-header, .sd-side-drawer-footer { flex-shrink: 0; - padding: 20px 24px; + padding: 12px 16px; + background-color: var(--sd-white, #fff); } .sd-side-drawer-content { - padding: 0 24px 20px; + padding: 12px 16px; } @media (max-width: 600px) { .sd-side-drawer { --sd-side-drawer-inset: 8px; } .sd-side-drawer-header { - padding: 16px; gap: 8px; } - .sd-side-drawer-content { - padding: 0 16px 16px; - } .sd-side-drawer-footer { padding: 12px 16px max(12px, env(safe-area-inset-bottom)); flex-wrap: wrap; diff --git a/versions/v21/projects/sdcorejs-angular/components/tab/sd-tab.md b/versions/v21/projects/sdcorejs-angular/components/tab/sd-tab.md index 918663850..5d4a9439f 100644 --- a/versions/v21/projects/sdcorejs-angular/components/tab/sd-tab.md +++ b/versions/v21/projects/sdcorejs-angular/components/tab/sd-tab.md @@ -34,7 +34,7 @@ Declarative tab container that wraps Angular Material's `mat-tab-group` with a s | Name | Type | Default | Notes | | ------------------- | ------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `selectedIndex` | `number` (model — two-way) | `0` | Index of the active tab. Two-way bindable via `[(selectedIndex)]`. Auto-clamped to `[0, tabs.length-1]` when the tab count shrinks. | -| `variant` | `'line' \| 'pills' \| 'segmented'` | `'line'` | Visual skin. `'line'` is Material's default underline ink-bar. `'pills'` renders each tab as a rounded pill with a filled active state (no underline) — useful for nested tab groups and filter bars where the default underline competes with the parent's. `'segmented'` renders a single bordered container with flush tabs (iOS segmented-control style). | +| `variant` | `'line' \| 'pills' \| 'segmented'` | `'line'` | Visual skin. `'line'` is Material's default underline ink-bar. `'pills'` renders each tab as a rounded pill with a filled active state (no underline) — useful for nested tab groups and filter bars where the default underline competes with the parent's. `'segmented'` renders a shared container with a 4px gap between tabs (iOS segmented-control style). Hover/ripple layers follow each tab's rounded shape; active pills retain their filled color on hover. | | `color` | `Color` (`'primary' \| 'secondary' \| 'info' \| 'success' \| 'warning' \| 'error'`) | `'primary'` | Drives the active-tab + indicator + badge colors via the Core CSS vars (`--sd-`, `--sd--light`). Same palette as ``, `` — pick `'warning'` for filter bars surfacing pending items, `'success'` for confirmed flows, etc. | | `headerPosition` | `'above' \| 'below'` | `'above'` | Forwarded to `mat-tab-group.headerPosition`. | | `alignTabs` | `'start' \| 'center' \| 'end'` | `'start'` | Forwarded to `mat-tab-group`'s `[mat-align-tabs]` input. **Only takes effect when `stretchTabs` is `false`** — otherwise tabs fill the row evenly and there's nothing to align. | diff --git a/versions/v21/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss b/versions/v21/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss index 7179666c2..b2949f7ca 100644 --- a/versions/v21/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss +++ b/versions/v21/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss @@ -102,6 +102,14 @@ opacity: 0 !important; } +// Material renders hover and click ripples in a separate rectangular layer. +// Clip that layer to the tab shape without clipping the label's focus indicator. +:host(.sd-tab-group--pills) ::ng-deep .mat-mdc-tab .mat-mdc-tab-ripple, +:host(.sd-tab-group--segmented) ::ng-deep .mat-mdc-tab .mat-mdc-tab-ripple { + border-radius: inherit; + overflow: hidden; +} + // ============================================================================= // Variant: pills — rounded pill tabs, active filled with primary. // Good for nested tab groups, filter bars, light contexts. @@ -132,7 +140,7 @@ background: transparent; transition: background-color 120ms ease, color 120ms ease; - &:hover:not(.mat-mdc-tab-disabled) { + &:hover:not(.mat-mdc-tab-disabled):not(.mdc-tab--active) { background: var(--sd-tab-badge-bg); } @@ -143,12 +151,6 @@ background: var(--sd-tab-label-active-color); .mdc-tab__text-label { color: #ffffff; } } - - // why: active pill darken on hover instead of the default whitish state-layer. - // box-shadow inset > filter because filter would dim text + icons too. - &.mdc-tab--active:hover:not(.mat-mdc-tab-disabled) { - box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0.12); - } } // Inside the pill we still allow the sd-tab__label inline-flex with icon/badge/close. @@ -166,7 +168,7 @@ } // ============================================================================= -// Variant: segmented — single bordered container, tabs sit flush. +// Variant: segmented — shared container with a small gap between tabs. // iOS-style segmented control. // ============================================================================= :host(.sd-tab-group--segmented) ::ng-deep { @@ -185,7 +187,7 @@ padding: 3px; } - .mat-mdc-tab-labels { gap: 0; } + .mat-mdc-tab-labels { gap: 4px; } .mat-mdc-tab { min-width: 0; diff --git a/versions/v21/projects/sdcorejs-angular/components/table/sd-table.md b/versions/v21/projects/sdcorejs-angular/components/table/sd-table.md index eee0bf1bc..bd8134c44 100644 --- a/versions/v21/projects/sdcorejs-angular/components/table/sd-table.md +++ b/versions/v21/projects/sdcorejs-angular/components/table/sd-table.md @@ -401,9 +401,11 @@ Client-side matching sống ở `matchesColumnFilter` (`services/table-local/tab Command icons default to Material Symbols Outlined (`material-icons-outlined`). Child command menu items use the same default unless `child.fontSet` is provided. Use icon + title for child commands so menu rows align consistently. +Desktop command `children`, selection action `children` and the selection toolbar More menu use a compact CDK overlay menu: white background, 8px corners, 36px minimum rows, 18px icons and a 4px gap from the trigger. Touch/narrow-screen menu rows remain at least 44px. Menus escape clipped table cells, support arrow-key/typeahead navigation and restore trigger focus on Escape or selection. Existing child visibility, disabled state, custom HTML and callbacks remain supported; no consumer configuration changes are required. + Desktop row commands keep their touch targets within each 24px button so adjacent actions and menu triggers remain independently clickable. Mobile action controls retain their existing sizing. -Disabled desktop row-command icons inherit the button's disabled color from the Material theme, making them visibly muted instead of retaining the active icon color. Child menu items retain Material's disabled opacity. +Disabled desktop row-command icons inherit the button's disabled color from the Material theme, making them visibly muted instead of retaining the active icon color. Disabled child menu items are muted and cannot be activated. ```ts command: { diff --git a/versions/v21/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss b/versions/v21/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss new file mode 100644 index 000000000..0a0a9bbc5 --- /dev/null +++ b/versions/v21/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss @@ -0,0 +1,67 @@ +.sd-table-action-menu { + display: flex; + flex-direction: column; + box-sizing: border-box; + min-width: 160px; + max-width: min(320px, calc(100vw - 16px)); + max-height: min(360px, calc(100dvh - 16px)); + overflow: auto; + padding: 4px; + border: 1px solid var(--sd-border, #dde0e5); + border-radius: 8px; + background: var(--sd-white, #fff); + color: var(--sd-text, #1a1b1f); + box-shadow: + 0 4px 12px rgba(15, 23, 42, 0.14), + 0 1px 3px rgba(15, 23, 42, 0.1); + + [cdkMenuItem] { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 36px; + padding: 8px 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font-family: inherit; + font-size: 14px; + line-height: 20px; + text-align: start; + white-space: normal; + overflow-wrap: anywhere; + cursor: pointer; + + &:hover:not(:disabled), + &:focus-visible { + background: var(--sd-black100, #f3f3f3); + } + + &:focus-visible { + outline: 2px solid var(--sd-primary, #005cbb); + outline-offset: -2px; + } + + &:disabled { + opacity: 0.45; + cursor: default; + } + } + + .sd-table-action-menu__icon { + flex: 0 0 18px; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } +} + +@media (max-width: 600px), (pointer: coarse) { + .sd-table-action-menu [cdkMenuItem] { + min-height: 44px; + } +} diff --git a/versions/v21/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts b/versions/v21/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts new file mode 100644 index 000000000..7cfd448fc --- /dev/null +++ b/versions/v21/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts @@ -0,0 +1,67 @@ +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { Directive, ElementRef, afterRenderEffect, inject, DestroyRef, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { SdButton } from '@sdcorejs/angular/components/button'; + +export const TABLE_ACTION_MENU_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 }, + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, +]; + +// why: CDK gắn trên host sd-button, nhưng focus/ARIA và Enter/Space phải thuộc nút HTML bên trong. +@Directive({ + selector: 'sd-button[sdTableMenuButton]', + standalone: true, + hostDirectives: [{ directive: CdkMenuTrigger, inputs: ['cdkMenuTriggerFor: sdTableMenuButton'] }], +}) +export class SdTableMenuButtonDirective { + readonly #host = inject>(ElementRef).nativeElement; + readonly #trigger = inject(CdkMenuTrigger); + readonly #sdButton = inject(SdButton); + readonly #opened = signal(false); + #button?: HTMLButtonElement; + + constructor() { + this.#trigger.menuPosition = TABLE_ACTION_MENU_POSITIONS; + const destroyRef = inject(DestroyRef); + this.#trigger.opened.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => this.#opened.set(true)); + this.#trigger.closed.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => { + this.#opened.set(false); + if (this.#button?.isConnected && this.#trigger.getMenu()?.nativeElement.contains(this.#host.ownerDocument.activeElement)) { + this.#button.focus(); + } + }); + afterRenderEffect(() => { + this.#sdButton.type(); + this.#opened(); + this.#syncButton(); + }); + destroyRef.onDestroy(() => this.#button?.removeEventListener('keydown', this.#nativeActivation, true)); + } + + readonly #nativeActivation = (event: KeyboardEvent): void => { + // why: để nút native tự phát click đúng một lần, không cho CDK toggle thêm ở keydown của host. + if (event.key === 'Enter' || event.key === ' ') event.stopPropagation(); + }; + + #syncButton(): void { + const button = this.#host.querySelector('button') ?? undefined; + if (button !== this.#button) { + this.#button?.removeEventListener('keydown', this.#nativeActivation, true); + this.#button = button; + button?.addEventListener('keydown', this.#nativeActivation, true); + } + button?.setAttribute('aria-haspopup', 'menu'); + button?.setAttribute('aria-expanded', String(this.#trigger.isOpen())); + const menuId = this.#trigger.getMenu()?.nativeElement.id; + if (menuId) button?.setAttribute('aria-controls', menuId); + else button?.removeAttribute('aria-controls'); + this.#host.removeAttribute('role'); + this.#host.removeAttribute('aria-haspopup'); + this.#host.removeAttribute('aria-expanded'); + this.#host.removeAttribute('aria-controls'); + } +} diff --git a/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html b/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html index 38cfbc7b3..9ed43877f 100644 --- a/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html +++ b/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html @@ -24,7 +24,9 @@ - - + +
@for (childCommand of command.children; track childCommand.key) { @let _childMeta = _item | command: childCommand; } - - +
+
} } } diff --git a/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss b/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss index c90841d55..fe78277aa 100644 --- a/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss +++ b/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss @@ -1,5 +1,6 @@ @use '../../../../../assets/scss/core/color.scss' as color; @use 'sass:map'; +@use '../action-menu/action-menu'; :host { display: flex; @@ -42,20 +43,8 @@ button[mat-icon-button]:disabled sd-icon.c-icon { width: 100%; } -// why: `margin-right: 0 !important` ở đây từng để chặn margin Material gắn vào `.mat-icon` trong -// mat-menu. sd-icon đã trung hoà ở component nên bỏ được; khoảng cách với nhãn là `gap` của -// .sd-command-menu-item__content. -.sd-command-menu-item__icon { - color: map.get(color.$color_map, 'text-secondary') !important; - width: 24px; - height: 24px; - font-size: 18px; - line-height: 24px !important; - flex: 0 0 24px; -} - .sd-command-menu-item__title { min-width: 0; line-height: 20px; - white-space: nowrap; + white-space: normal; } diff --git a/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts b/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts index 79a9b04b1..03e5eff62 100644 --- a/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts +++ b/versions/v21/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts @@ -54,7 +54,7 @@ describe('DesktopCommand', () => { fixture.detectChanges(); await fixture.whenStable(); - const menu = document.body.querySelector('.mat-mdc-menu-panel') as HTMLElement; + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; const content = menu.querySelector('.sd-command-menu-item__content') as HTMLElement; const icon = menu.querySelector('mat-icon') as HTMLElement; const title = menu.querySelector('.sd-command-menu-item__title') as HTMLElement; @@ -62,6 +62,71 @@ describe('DesktopCommand', () => { expect(content).not.toBeNull(); expect(icon.classList).toContain('material-icons-outlined'); expect(title.textContent?.trim()).toBe('Edit'); + expect(menu.classList).toContain('sd-table-action-menu'); + expect(menu.querySelector('button')!.getBoundingClientRect().height).toBeLessThanOrEqual(36); + }); + + it('keeps hidden/disabled child rules and restores the command trigger after Escape', async () => { + const clicked = jasmine.createSpy('clicked'); + fixture.componentInstance.commands = [ + { + title: 'More', + children: [ + { title: 'Hidden', hidden: async () => true, click: clicked }, + { title: 'Disabled', disabled: true, click: clicked }, + { title: 'Allowed', click: clicked }, + ], + }, + ]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector('button') as HTMLButtonElement; + trigger.focus(); + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40, bubbles: true })); + fixture.detectChanges(); + await fixture.whenStable(); + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; + expect(menu.textContent).not.toContain('Hidden'); + const items = Array.from(menu.querySelectorAll('button')); + items[0].click(); + expect(clicked).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(items[1]); + const escape = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }); + items[1].dispatchEvent(escape); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('renders outside a clipped row and invokes the child once with its row data', async () => { + const clicked = jasmine.createSpy('clicked'); + Object.assign(fixture.nativeElement.style, { + position: 'fixed', + top: '32px', + left: '32px', + width: '80px', + height: '24px', + overflow: 'hidden', + }); + fixture.componentInstance.commands = [{ title: 'More', children: [{ title: 'Run', click: clicked }] }]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + await fixture.whenStable(); + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; + expect(fixture.nativeElement.contains(menu)).toBeFalse(); + const item = menu.querySelector('button')!; + const bounds = item.getBoundingClientRect(); + expect(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)?.closest('button')).toBe(item); + item.click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(clicked).toHaveBeenCalledOnceWith(fixture.componentInstance.item.data); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); }); // why: hai nút command từng mang aria-hidden="true" — chúng là - } -
+ [sdTableMenuButton]="childrenMenu" /> + +
+ @for (child of action.children; track $index) { + + } +
+
} } @if (_overflow.length) { @@ -55,44 +65,55 @@ class="sd-selection-more" size="sm" prefixIcon="more_horiz" - [title]="moreLabel()" + [title]="_moreLabel" color="secondary" type="light" [autoId]="_autoId ? _autoId + '-more-actions' : null" - [matMenuTriggerFor]="moreMenu" /> - - @for (entry of _overflow; track entry.index) { - @let action = entry.action; - @if (action.variant === 'normal') { - - } @else { -
-
{{ action.title }}
- @for (child of action.children; track $index) { - - } -
+ [sdTableMenuButton]="moreMenu" /> + +
+ @for (entry of _overflow; track entry.index) { + @let action = entry.action; + @if (action.variant === 'normal') { + + } @else { +
+
{{ action.title }}
+ @for (child of action.children; track $index) { + + } +
+ } } - } - +
+
} + } @else { +
+ +
+ } } @else {
+ [fontSet]="_iconFontSet" + [size]="_structured ? '20px' : undefined"> } } - {{ node.title }} + {{ node.title }} - @if (showPin()) { + @if (_showPin) { - + +
- - @if (group.open) { - -
+ + + +
- } +
@if (!isRoot) { - + }
diff --git a/versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss b/versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss index 3025f6831..e631045ce 100644 --- a/versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss +++ b/versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.scss @@ -87,9 +87,6 @@ $node-gap: 12px; position: relative; z-index: 1; - &.is-open { - z-index: 1000; - } } // AND / OR segmented toggle — active side uses primary. @@ -167,16 +164,11 @@ $node-gap: 12px; } .qb-dropdown { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; background: #fff; border: 1px solid $border; box-shadow: 0 4px 12px rgba(16, 24, 40, 0.12); border-radius: 6px; min-width: 150px; - z-index: 100; overflow: hidden; button { diff --git a/versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts b/versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts index b8957417f..c90472925 100644 --- a/versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts +++ b/versions/v22/projects/sdcorejs-angular/components/query-builder/src/query-builder.component.ts @@ -1,4 +1,6 @@ import { NgTemplateOutlet } from '@angular/common'; +import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay'; +import { CdkTrapFocus } from '@angular/cdk/a11y'; import { ChangeDetectionStrategy, Component, @@ -70,12 +72,31 @@ const QB_EMPTY_FIELDS: SdQueryBuilderField[] = []; selector: 'sd-query-builder', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SdIcon, NgTemplateOutlet, SdOperator, SdSelect, SdInput, SdInputNumber, SdDate, SdDatetime, SdItemDefDefDirective], + imports: [ + SdIcon, + NgTemplateOutlet, + SdOperator, + SdSelect, + SdInput, + SdInputNumber, + SdDate, + SdDatetime, + SdItemDefDefDirective, + CdkConnectedOverlay, + CdkOverlayOrigin, + CdkTrapFocus, + ], templateUrl: './query-builder.component.html', styleUrl: './query-builder.component.scss', }) export class SdQueryBuilder { readonly #i18n = inject(I18nService); + protected readonly addMenuPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 }, + ]; // ------------------------------------------------------------------------- // Inputs @@ -232,6 +253,11 @@ export class SdQueryBuilder { readonly fieldIcon = sdQbFieldIcon; constructor() { + effect(() => { + if (this.resolvedDisabled() || this.isView()) { + untracked(() => this.closeAllDropdowns()); + } + }); // Inbound seeding: an EXTERNAL write to value / filters rebuilds the tree. // Our own emits are recognised via #lastEmitted and skipped (no echo loop). effect(() => { @@ -311,6 +337,13 @@ export class SdQueryBuilder { this.#bumpTree(); } + protected onAddMenuKeydown(event: KeyboardEvent): void { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + this.closeAllDropdowns(); + } + #hasOpenDropdown(node: SdQbNode): boolean { if (!sdIsQbGroup(node)) return false; return node.open === true || node.children.some(c => this.#hasOpenDropdown(c)); diff --git a/versions/v22/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md b/versions/v22/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md index 0344e62c9..e2d011c0f 100644 --- a/versions/v22/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md +++ b/versions/v22/projects/sdcorejs-angular/components/side-drawer/sd-side-drawer.md @@ -37,7 +37,7 @@ import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer'; | `[sdFooterLeft]` | Footer left action group. | | `[sdFooterRight]` | Footer right action group. | -Header, body and footer align to 24px horizontal insets (16px on mobile). Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. +Header and footer use a white background (`--sd-white`, default `#fff`) and compact 12px vertical / 16px horizontal padding. Body content uses `padding: 12px 16px` on desktop and mobile, giving it its own top and bottom spacing within the separate body surface. Body content scrolls, while the footer remains available and wraps on narrow screens. The footer is hidden when both footer slots are empty. ## Public API @@ -88,7 +88,7 @@ Do not write `document.body.style.overflow` yourself while a drawer is open; the display: flex; flex-direction: column; gap: 12px; - padding: 16px; + padding: 0; // The drawer supplies the content insets. } ``` @@ -105,4 +105,4 @@ Each open creates a CDK focus trap around the drawer content. Focus moves inside Opening makes the drawer visible immediately so focus can enter before the slide/fade animation finishes. Closing delays the hidden state until that animation completes. Reduced motion removes these transitions. -The default radius is 8px, overridable through --sd-overlay-radius. Header/body/footer align at 24px horizontally (16px below 600px); the body scrolls while footer actions remain available. Close/actions are at least 44px on mobile, footer groups wrap, and reduced motion is enforced by the component itself. +The default radius is 8px, overridable through --sd-overlay-radius. Header/footer use 12px vertical padding and header/body/footer align at 16px horizontally; the body scrolls while footer actions remain available. Close/actions are at least 44px on mobile, footer groups wrap, and reduced motion is enforced by the component itself. diff --git a/versions/v22/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss b/versions/v22/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss index 1c1ea5c49..603a8d8cb 100644 --- a/versions/v22/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss +++ b/versions/v22/projects/sdcorejs-angular/components/side-drawer/src/side-drawer.component.scss @@ -37,8 +37,7 @@ } &-header { - min-height: 64px; - padding: 16px; + min-height: 56px; display: flex; align-items: center; justify-content: space-between; @@ -131,8 +130,6 @@ &-footer { border-top: 1px solid var(--sd-side-drawer-border); - padding: 16px; - background-color: var(--sd-side-drawer-surface); display: flex; align-items: center; justify-content: space-between; @@ -197,22 +194,19 @@ .sd-side-drawer-header, .sd-side-drawer-footer { flex-shrink: 0; - padding: 20px 24px; + padding: 12px 16px; + background-color: var(--sd-white, #fff); } .sd-side-drawer-content { - padding: 0 24px 20px; + padding: 12px 16px; } @media (max-width: 600px) { .sd-side-drawer { --sd-side-drawer-inset: 8px; } .sd-side-drawer-header { - padding: 16px; gap: 8px; } - .sd-side-drawer-content { - padding: 0 16px 16px; - } .sd-side-drawer-footer { padding: 12px 16px max(12px, env(safe-area-inset-bottom)); flex-wrap: wrap; diff --git a/versions/v22/projects/sdcorejs-angular/components/tab/sd-tab.md b/versions/v22/projects/sdcorejs-angular/components/tab/sd-tab.md index 918663850..5d4a9439f 100644 --- a/versions/v22/projects/sdcorejs-angular/components/tab/sd-tab.md +++ b/versions/v22/projects/sdcorejs-angular/components/tab/sd-tab.md @@ -34,7 +34,7 @@ Declarative tab container that wraps Angular Material's `mat-tab-group` with a s | Name | Type | Default | Notes | | ------------------- | ------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `selectedIndex` | `number` (model — two-way) | `0` | Index of the active tab. Two-way bindable via `[(selectedIndex)]`. Auto-clamped to `[0, tabs.length-1]` when the tab count shrinks. | -| `variant` | `'line' \| 'pills' \| 'segmented'` | `'line'` | Visual skin. `'line'` is Material's default underline ink-bar. `'pills'` renders each tab as a rounded pill with a filled active state (no underline) — useful for nested tab groups and filter bars where the default underline competes with the parent's. `'segmented'` renders a single bordered container with flush tabs (iOS segmented-control style). | +| `variant` | `'line' \| 'pills' \| 'segmented'` | `'line'` | Visual skin. `'line'` is Material's default underline ink-bar. `'pills'` renders each tab as a rounded pill with a filled active state (no underline) — useful for nested tab groups and filter bars where the default underline competes with the parent's. `'segmented'` renders a shared container with a 4px gap between tabs (iOS segmented-control style). Hover/ripple layers follow each tab's rounded shape; active pills retain their filled color on hover. | | `color` | `Color` (`'primary' \| 'secondary' \| 'info' \| 'success' \| 'warning' \| 'error'`) | `'primary'` | Drives the active-tab + indicator + badge colors via the Core CSS vars (`--sd-`, `--sd--light`). Same palette as ``, `` — pick `'warning'` for filter bars surfacing pending items, `'success'` for confirmed flows, etc. | | `headerPosition` | `'above' \| 'below'` | `'above'` | Forwarded to `mat-tab-group.headerPosition`. | | `alignTabs` | `'start' \| 'center' \| 'end'` | `'start'` | Forwarded to `mat-tab-group`'s `[mat-align-tabs]` input. **Only takes effect when `stretchTabs` is `false`** — otherwise tabs fill the row evenly and there's nothing to align. | diff --git a/versions/v22/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss b/versions/v22/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss index 7179666c2..b2949f7ca 100644 --- a/versions/v22/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss +++ b/versions/v22/projects/sdcorejs-angular/components/tab/src/tab-group.component.scss @@ -102,6 +102,14 @@ opacity: 0 !important; } +// Material renders hover and click ripples in a separate rectangular layer. +// Clip that layer to the tab shape without clipping the label's focus indicator. +:host(.sd-tab-group--pills) ::ng-deep .mat-mdc-tab .mat-mdc-tab-ripple, +:host(.sd-tab-group--segmented) ::ng-deep .mat-mdc-tab .mat-mdc-tab-ripple { + border-radius: inherit; + overflow: hidden; +} + // ============================================================================= // Variant: pills — rounded pill tabs, active filled with primary. // Good for nested tab groups, filter bars, light contexts. @@ -132,7 +140,7 @@ background: transparent; transition: background-color 120ms ease, color 120ms ease; - &:hover:not(.mat-mdc-tab-disabled) { + &:hover:not(.mat-mdc-tab-disabled):not(.mdc-tab--active) { background: var(--sd-tab-badge-bg); } @@ -143,12 +151,6 @@ background: var(--sd-tab-label-active-color); .mdc-tab__text-label { color: #ffffff; } } - - // why: active pill darken on hover instead of the default whitish state-layer. - // box-shadow inset > filter because filter would dim text + icons too. - &.mdc-tab--active:hover:not(.mat-mdc-tab-disabled) { - box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0.12); - } } // Inside the pill we still allow the sd-tab__label inline-flex with icon/badge/close. @@ -166,7 +168,7 @@ } // ============================================================================= -// Variant: segmented — single bordered container, tabs sit flush. +// Variant: segmented — shared container with a small gap between tabs. // iOS-style segmented control. // ============================================================================= :host(.sd-tab-group--segmented) ::ng-deep { @@ -185,7 +187,7 @@ padding: 3px; } - .mat-mdc-tab-labels { gap: 0; } + .mat-mdc-tab-labels { gap: 4px; } .mat-mdc-tab { min-width: 0; diff --git a/versions/v22/projects/sdcorejs-angular/components/table/sd-table.md b/versions/v22/projects/sdcorejs-angular/components/table/sd-table.md index eee0bf1bc..bd8134c44 100644 --- a/versions/v22/projects/sdcorejs-angular/components/table/sd-table.md +++ b/versions/v22/projects/sdcorejs-angular/components/table/sd-table.md @@ -401,9 +401,11 @@ Client-side matching sống ở `matchesColumnFilter` (`services/table-local/tab Command icons default to Material Symbols Outlined (`material-icons-outlined`). Child command menu items use the same default unless `child.fontSet` is provided. Use icon + title for child commands so menu rows align consistently. +Desktop command `children`, selection action `children` and the selection toolbar More menu use a compact CDK overlay menu: white background, 8px corners, 36px minimum rows, 18px icons and a 4px gap from the trigger. Touch/narrow-screen menu rows remain at least 44px. Menus escape clipped table cells, support arrow-key/typeahead navigation and restore trigger focus on Escape or selection. Existing child visibility, disabled state, custom HTML and callbacks remain supported; no consumer configuration changes are required. + Desktop row commands keep their touch targets within each 24px button so adjacent actions and menu triggers remain independently clickable. Mobile action controls retain their existing sizing. -Disabled desktop row-command icons inherit the button's disabled color from the Material theme, making them visibly muted instead of retaining the active icon color. Child menu items retain Material's disabled opacity. +Disabled desktop row-command icons inherit the button's disabled color from the Material theme, making them visibly muted instead of retaining the active icon color. Disabled child menu items are muted and cannot be activated. ```ts command: { diff --git a/versions/v22/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss b/versions/v22/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss new file mode 100644 index 000000000..0a0a9bbc5 --- /dev/null +++ b/versions/v22/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.scss @@ -0,0 +1,67 @@ +.sd-table-action-menu { + display: flex; + flex-direction: column; + box-sizing: border-box; + min-width: 160px; + max-width: min(320px, calc(100vw - 16px)); + max-height: min(360px, calc(100dvh - 16px)); + overflow: auto; + padding: 4px; + border: 1px solid var(--sd-border, #dde0e5); + border-radius: 8px; + background: var(--sd-white, #fff); + color: var(--sd-text, #1a1b1f); + box-shadow: + 0 4px 12px rgba(15, 23, 42, 0.14), + 0 1px 3px rgba(15, 23, 42, 0.1); + + [cdkMenuItem] { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 36px; + padding: 8px 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font-family: inherit; + font-size: 14px; + line-height: 20px; + text-align: start; + white-space: normal; + overflow-wrap: anywhere; + cursor: pointer; + + &:hover:not(:disabled), + &:focus-visible { + background: var(--sd-black100, #f3f3f3); + } + + &:focus-visible { + outline: 2px solid var(--sd-primary, #005cbb); + outline-offset: -2px; + } + + &:disabled { + opacity: 0.45; + cursor: default; + } + } + + .sd-table-action-menu__icon { + flex: 0 0 18px; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } +} + +@media (max-width: 600px), (pointer: coarse) { + .sd-table-action-menu [cdkMenuItem] { + min-height: 44px; + } +} diff --git a/versions/v22/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts b/versions/v22/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts new file mode 100644 index 000000000..7cfd448fc --- /dev/null +++ b/versions/v22/projects/sdcorejs-angular/components/table/src/components/action-menu/action-menu.ts @@ -0,0 +1,67 @@ +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { Directive, ElementRef, afterRenderEffect, inject, DestroyRef, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { SdButton } from '@sdcorejs/angular/components/button'; + +export const TABLE_ACTION_MENU_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 }, + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, +]; + +// why: CDK gắn trên host sd-button, nhưng focus/ARIA và Enter/Space phải thuộc nút HTML bên trong. +@Directive({ + selector: 'sd-button[sdTableMenuButton]', + standalone: true, + hostDirectives: [{ directive: CdkMenuTrigger, inputs: ['cdkMenuTriggerFor: sdTableMenuButton'] }], +}) +export class SdTableMenuButtonDirective { + readonly #host = inject>(ElementRef).nativeElement; + readonly #trigger = inject(CdkMenuTrigger); + readonly #sdButton = inject(SdButton); + readonly #opened = signal(false); + #button?: HTMLButtonElement; + + constructor() { + this.#trigger.menuPosition = TABLE_ACTION_MENU_POSITIONS; + const destroyRef = inject(DestroyRef); + this.#trigger.opened.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => this.#opened.set(true)); + this.#trigger.closed.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => { + this.#opened.set(false); + if (this.#button?.isConnected && this.#trigger.getMenu()?.nativeElement.contains(this.#host.ownerDocument.activeElement)) { + this.#button.focus(); + } + }); + afterRenderEffect(() => { + this.#sdButton.type(); + this.#opened(); + this.#syncButton(); + }); + destroyRef.onDestroy(() => this.#button?.removeEventListener('keydown', this.#nativeActivation, true)); + } + + readonly #nativeActivation = (event: KeyboardEvent): void => { + // why: để nút native tự phát click đúng một lần, không cho CDK toggle thêm ở keydown của host. + if (event.key === 'Enter' || event.key === ' ') event.stopPropagation(); + }; + + #syncButton(): void { + const button = this.#host.querySelector('button') ?? undefined; + if (button !== this.#button) { + this.#button?.removeEventListener('keydown', this.#nativeActivation, true); + this.#button = button; + button?.addEventListener('keydown', this.#nativeActivation, true); + } + button?.setAttribute('aria-haspopup', 'menu'); + button?.setAttribute('aria-expanded', String(this.#trigger.isOpen())); + const menuId = this.#trigger.getMenu()?.nativeElement.id; + if (menuId) button?.setAttribute('aria-controls', menuId); + else button?.removeAttribute('aria-controls'); + this.#host.removeAttribute('role'); + this.#host.removeAttribute('aria-haspopup'); + this.#host.removeAttribute('aria-expanded'); + this.#host.removeAttribute('aria-controls'); + } +} diff --git a/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html b/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html index 38cfbc7b3..9ed43877f 100644 --- a/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html +++ b/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.html @@ -24,7 +24,9 @@ - - + +
@for (childCommand of command.children; track childCommand.key) { @let _childMeta = _item | command: childCommand; } - - +
+
} } } diff --git a/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss b/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss index c90841d55..fe78277aa 100644 --- a/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss +++ b/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.scss @@ -1,5 +1,6 @@ @use '../../../../../assets/scss/core/color.scss' as color; @use 'sass:map'; +@use '../action-menu/action-menu'; :host { display: flex; @@ -42,20 +43,8 @@ button[mat-icon-button]:disabled sd-icon.c-icon { width: 100%; } -// why: `margin-right: 0 !important` ở đây từng để chặn margin Material gắn vào `.mat-icon` trong -// mat-menu. sd-icon đã trung hoà ở component nên bỏ được; khoảng cách với nhãn là `gap` của -// .sd-command-menu-item__content. -.sd-command-menu-item__icon { - color: map.get(color.$color_map, 'text-secondary') !important; - width: 24px; - height: 24px; - font-size: 18px; - line-height: 24px !important; - flex: 0 0 24px; -} - .sd-command-menu-item__title { min-width: 0; line-height: 20px; - white-space: nowrap; + white-space: normal; } diff --git a/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts b/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts index 9e6a20aed..98e74035f 100644 --- a/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts +++ b/versions/v22/projects/sdcorejs-angular/components/table/src/components/command/desktop-command.component.spec.ts @@ -56,7 +56,7 @@ describe('DesktopCommand', () => { fixture.detectChanges(); await fixture.whenStable(); - const menu = document.body.querySelector('.mat-mdc-menu-panel') as HTMLElement; + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; const content = menu.querySelector('.sd-command-menu-item__content') as HTMLElement; const icon = menu.querySelector('mat-icon') as HTMLElement; const title = menu.querySelector('.sd-command-menu-item__title') as HTMLElement; @@ -64,6 +64,71 @@ describe('DesktopCommand', () => { expect(content).not.toBeNull(); expect(icon.classList).toContain('material-icons-outlined'); expect(title.textContent?.trim()).toBe('Edit'); + expect(menu.classList).toContain('sd-table-action-menu'); + expect(menu.querySelector('button')!.getBoundingClientRect().height).toBeLessThanOrEqual(36); + }); + + it('keeps hidden/disabled child rules and restores the command trigger after Escape', async () => { + const clicked = jasmine.createSpy('clicked'); + fixture.componentInstance.commands = [ + { + title: 'More', + children: [ + { title: 'Hidden', hidden: async () => true, click: clicked }, + { title: 'Disabled', disabled: true, click: clicked }, + { title: 'Allowed', click: clicked }, + ], + }, + ]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector('button') as HTMLButtonElement; + trigger.focus(); + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40, bubbles: true })); + fixture.detectChanges(); + await fixture.whenStable(); + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; + expect(menu.textContent).not.toContain('Hidden'); + const items = Array.from(menu.querySelectorAll('button')); + items[0].click(); + expect(clicked).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(items[1]); + const escape = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }); + items[1].dispatchEvent(escape); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('renders outside a clipped row and invokes the child once with its row data', async () => { + const clicked = jasmine.createSpy('clicked'); + Object.assign(fixture.nativeElement.style, { + position: 'fixed', + top: '32px', + left: '32px', + width: '80px', + height: '24px', + overflow: 'hidden', + }); + fixture.componentInstance.commands = [{ title: 'More', children: [{ title: 'Run', click: clicked }] }]; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + await fixture.whenStable(); + const menu = document.body.querySelector('[role="menu"]') as HTMLElement; + expect(fixture.nativeElement.contains(menu)).toBeFalse(); + const item = menu.querySelector('button')!; + const bounds = item.getBoundingClientRect(); + expect(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)?.closest('button')).toBe(item); + item.click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(clicked).toHaveBeenCalledOnceWith(fixture.componentInstance.item.data); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); }); // why: hai nút command từng mang aria-hidden="true" — chúng là - } -
+ [sdTableMenuButton]="childrenMenu" /> + +
+ @for (child of action.children; track $index) { + + } +
+
} } @if (_overflow.length) { @@ -55,44 +65,55 @@ class="sd-selection-more" size="sm" prefixIcon="more_horiz" - [title]="moreLabel()" + [title]="_moreLabel" color="secondary" type="light" [autoId]="_autoId ? _autoId + '-more-actions' : null" - [matMenuTriggerFor]="moreMenu" /> - - @for (entry of _overflow; track entry.index) { - @let action = entry.action; - @if (action.variant === 'normal') { - - } @else { -
-
{{ action.title }}
- @for (child of action.children; track $index) { - - } -
+ [sdTableMenuButton]="moreMenu" /> + +
+ @for (entry of _overflow; track entry.index) { + @let action = entry.action; + @if (action.variant === 'normal') { + + } @else { +
+
{{ action.title }}
+ @for (child of action.children; track $index) { + + } +
+ } } - } - +
+
} + } @else { +
+ +
+ } } @else {
+ [fontSet]="_iconFontSet" + [size]="_structured ? '20px' : undefined"> } } - {{ node.title }} + {{ node.title }} - @if (showPin()) { + @if (_showPin) {