diff --git a/CHANGELOG.md b/CHANGELOG.md index 149c0e13f..2109d4609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Format dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Maj ### Added +- `sd-switch` supports `size="sm | md | lg"` (default `md`) with matching track, handle, icon and focus-layer dimensions. Switches rendered inside `sd-table`, including consumer cell templates, use the compact `sm` appearance automatically. - 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. diff --git a/showcase/src/app/docs/core/documentation.registry.ts b/showcase/src/app/docs/core/documentation.registry.ts index 04c57d586..8b54a5848 100644 --- a/showcase/src/app/docs/core/documentation.registry.ts +++ b/showcase/src/app/docs/core/documentation.registry.ts @@ -775,13 +775,13 @@ const FORM_PAGES = [ category: 'forms', slug: 'switch', title: 'Switch', - description: 'Công tắc boolean với theme colors và các trạng thái disabled/viewed.', + description: 'Công tắc boolean với ba kích thước, theme colors và các trạng thái disabled/viewed.', selector: 'sd-switch', importPath: '@sdcorejs/angular/forms/switch', publishedDocId: 'forms/switch/sd-switch', - keywords: ['toggle', 'boolean', 'on off', 'settings'], + keywords: ['toggle', 'boolean', 'on off', 'settings', 'size'], status: 'stable', - demoSectionCount: 5, + demoSectionCount: 7, loadComponent: () => import('../../pages/forms/switch/switch-demo.component').then(m => m.SwitchDemoComponent), }), defineDocPage({ diff --git a/showcase/src/app/docs/generated/example-manifest.generated.ts b/showcase/src/app/docs/generated/example-manifest.generated.ts index 3fb43bb3a..3433fdc2b 100644 --- a/showcase/src/app/docs/generated/example-manifest.generated.ts +++ b/showcase/src/app/docs/generated/example-manifest.generated.ts @@ -281,9 +281,11 @@ export type ShowcaseExampleSourceKey = | "forms/select/example-snippet-mau" | "forms/select/example-trang-thai" | "forms/select/example-validator" + | "forms/switch/example-ben-trong-bang" | "forms/switch/example-che-do-xem" | "forms/switch/example-co-ban" | "forms/switch/example-danh-sach-cau-hinh" + | "forms/switch/example-kich-thuoc" | "forms/switch/example-mau-sac" | "forms/switch/example-trang-thai" | "forms/textarea/example-chinh-sua-noi-tuyen" @@ -2329,6 +2331,20 @@ export const SHOWCASE_EXAMPLE_MANIFEST = [ title: "Cơ bản", description: "Bind hai chiều, hiển thị trạng thái ngay bên cạnh.", }, + { + sourceKey: "forms/switch/example-kich-thuoc", + pageKey: "forms/switch", + sectionId: "example-kich-thuoc", + title: "Kích thước", + description: "Mặc định md. Track lần lượt 36×20px, 52×32px và 60×36px.", + }, + { + sourceKey: "forms/switch/example-ben-trong-bang", + pageKey: "forms/switch", + sectionId: "example-ben-trong-bang", + title: "Bên trong bảng", + description: "Switch trong sd-table tự hiển thị cỡ sm, kể cả khi cell template không truyền size.", + }, { sourceKey: "forms/switch/example-danh-sach-cau-hinh", pageKey: "forms/switch", diff --git a/showcase/src/app/docs/generated/example-sources.generated.ts b/showcase/src/app/docs/generated/example-sources.generated.ts index 642335969..e1dfeb036 100644 --- a/showcase/src/app/docs/generated/example-sources.generated.ts +++ b/showcase/src/app/docs/generated/example-sources.generated.ts @@ -11437,13 +11437,14 @@ export class SelectDemoComponent { import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { DemoPageComponent, DemoSectionComponent } from '../../../shared/demo-page.component'; import { SdSwitch } from '@sdcorejs/angular/forms/switch'; +import { SdTable, SdTableCellDefDirective, SdTableOption } from '@sdcorejs/angular/components/table'; @Component({ selector: 'app-switch-demo', standalone: true, - imports: [DemoPageComponent, DemoSectionComponent, FormsModule, ReactiveFormsModule, SdSwitch], + imports: [DemoPageComponent, DemoSectionComponent, FormsModule, ReactiveFormsModule, SdSwitch, SdTable, SdTableCellDefDirective], template: \` - + @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-co-ban') {
@@ -11455,6 +11456,35 @@ import { SdSwitch } from '@sdcorejs/angular/forms/switch'; } + @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-kich-thuoc') { + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ } + + @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-ben-trong-bang') { + + + + + + + + } + @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-danh-sach-cau-hinh') {
@@ -11502,6 +11532,19 @@ import { SdSwitch } from '@sdcorejs/angular/forms/switch'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SwitchDemoComponent { + readonly switchRows = [ + { id: 1, name: 'Thông báo email', active: true }, + { id: 2, name: 'Tự động lưu', active: false }, + ]; + readonly switchTable: SdTableOption<(typeof this.switchRows)[number]> = { + type: 'local', + items: () => this.switchRows, + columns: [ + { field: 'name', title: 'Tính năng', type: 'string' }, + { field: 'active', title: 'Trạng thái', type: 'boolean' }, + ], + }; + form = new FormGroup({}); notify = signal(true); @@ -19162,6 +19205,16 @@ export const SHOWCASE_EXAMPLE_SOURCES = {
`, }, + "forms/switch/example-ben-trong-bang": { + ...SHOWCASE_PAGE_SOURCES["forms/switch"], + html: ` + + + + + + `, + }, "forms/switch/example-che-do-xem": { ...SHOWCASE_PAGE_SOURCES["forms/switch"], html: ` @@ -19196,6 +19249,25 @@ export const SHOWCASE_EXAMPLE_SOURCES = {
`, }, + "forms/switch/example-kich-thuoc": { + ...SHOWCASE_PAGE_SOURCES["forms/switch"], + html: ` +
+
+ + +
+
+ + +
+
+ + +
+
+
`, + }, "forms/switch/example-mau-sac": { ...SHOWCASE_PAGE_SOURCES["forms/switch"], html: ` diff --git a/showcase/src/app/pages/forms/switch/switch-demo.component.ts b/showcase/src/app/pages/forms/switch/switch-demo.component.ts index 5f8f7ad07..fe6ec098f 100644 --- a/showcase/src/app/pages/forms/switch/switch-demo.component.ts +++ b/showcase/src/app/pages/forms/switch/switch-demo.component.ts @@ -2,13 +2,14 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { DemoPageComponent, DemoSectionComponent } from '../../../shared/demo-page.component'; import { SdSwitch } from '@sdcorejs/angular/forms/switch'; +import { SdTable, SdTableCellDefDirective, SdTableOption } from '@sdcorejs/angular/components/table'; @Component({ selector: 'app-switch-demo', standalone: true, - imports: [DemoPageComponent, DemoSectionComponent, FormsModule, ReactiveFormsModule, SdSwitch], + imports: [DemoPageComponent, DemoSectionComponent, FormsModule, ReactiveFormsModule, SdSwitch, SdTable, SdTableCellDefDirective], template: ` - + @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-co-ban') {
@@ -20,6 +21,35 @@ import { SdSwitch } from '@sdcorejs/angular/forms/switch'; } + @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-kich-thuoc') { + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ } + + @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-ben-trong-bang') { + + + + + + + + } + @if (!demoPage.focusedSectionId || demoPage.focusedSectionId === 'example-danh-sach-cau-hinh') {
@@ -67,6 +97,19 @@ import { SdSwitch } from '@sdcorejs/angular/forms/switch'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SwitchDemoComponent { + readonly switchRows = [ + { id: 1, name: 'Thông báo email', active: true }, + { id: 2, name: 'Tự động lưu', active: false }, + ]; + readonly switchTable: SdTableOption<(typeof this.switchRows)[number]> = { + type: 'local', + items: () => this.switchRows, + columns: [ + { field: 'name', title: 'Tính năng', type: 'string' }, + { field: 'active', title: 'Trạng thái', type: 'boolean' }, + ], + }; + form = new FormGroup({}); notify = signal(true); diff --git a/versions/v19/SYNC-STATUS.md b/versions/v19/SYNC-STATUS.md index 50727106d..2e7612b88 100644 --- a/versions/v19/SYNC-STATUS.md +++ b/versions/v19/SYNC-STATUS.md @@ -4,7 +4,7 @@ |-----|-------| | Angular Major | 19 | | Legacy Source Commit | d12478a1 | -| Updated At | 2026-09-11 15:55:24 | +| Updated At | 2026-09-11 16:48:14 | | Origin | repo-owned versions/v19 (final legacy sync vn-angular@d12478a1) | | Workspace Flow | versions/v19 | | Development Mode | repo-owned independent pack | diff --git a/versions/v19/projects/sdcorejs-angular/components/table/sd-table.md b/versions/v19/projects/sdcorejs-angular/components/table/sd-table.md index bd8134c44..986508fa8 100644 --- a/versions/v19/projects/sdcorejs-angular/components/table/sd-table.md +++ b/versions/v19/projects/sdcorejs-angular/components/table/sd-table.md @@ -425,6 +425,10 @@ command: { } ``` +### Switches in cells + +Switches (`sd-switch`) rendered within the table use the compact `sm` appearance (36×20px track), including projected consumer cell templates and mobile cards. This table styling takes precedence over the switch size; switches outside the table still default to `md`. + ## Inputs (the host element) | Name | Type | Default | Notes | diff --git a/versions/v19/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts b/versions/v19/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts new file mode 100644 index 000000000..4f0edb638 --- /dev/null +++ b/versions/v19/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SdSwitch } from '@sdcorejs/angular/forms/switch'; +import { SdTable } from './table.component'; +import { SdTableCellDefDirective } from './directives/sd-table-cell-def.directive'; +import { SdTableOption } from './models/table-option.model'; + +@Component({ + imports: [SdTable, SdTableCellDefDirective, SdSwitch], + template: ` + + + + + + + + `, +}) +class SwitchTableHost { + row = { id: 1, active: false }; + option: SdTableOption = { + type: 'local', + items: () => [this.row], + columns: [{ field: 'active', title: 'Active', type: 'boolean' }], + }; +} + +describe('SdTable switch sizing', () => { + it('compacts a consumer cell switch to sm without affecting an outside switch or its model binding', fakeAsync(() => { + TestBed.configureTestingModule({ imports: [SwitchTableHost, NoopAnimationsModule] }); + const fixture = TestBed.createComponent(SwitchTableHost); + fixture.detectChanges(); + tick(800); + flush(); + fixture.detectChanges(); + const inside = fixture.nativeElement.querySelector('sd-table sd-switch') as HTMLElement; + const outside = fixture.nativeElement.querySelector('sd-switch') as HTMLElement; + expect(inside).not.toBeNull(); + expect(inside.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(36); + expect(inside.querySelector('.mdc-switch__track')!.getBoundingClientRect().height).toBe(20); + const explicitLarge = fixture.nativeElement.querySelector('sd-table sd-switch[size="lg"]') as HTMLElement; + expect(explicitLarge.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(36); + expect(outside.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(52); + inside.querySelector('button[role="switch"]')!.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.row.active).toBeTrue(); + fixture.destroy(); + flush(); + })); +}); diff --git a/versions/v19/projects/sdcorejs-angular/forms/switch/sd-switch.md b/versions/v19/projects/sdcorejs-angular/forms/switch/sd-switch.md index a8f287721..01e6b2c4e 100644 --- a/versions/v19/projects/sdcorejs-angular/forms/switch/sd-switch.md +++ b/versions/v19/projects/sdcorejs-angular/forms/switch/sd-switch.md @@ -30,7 +30,7 @@ iOS-style toggle switch — boolean ON/OFF in a single tap. Use for feature flag | ----------------- | ------------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `autoId` | `string \| null \| undefined` | `undefined` | Generates `data-autoId="forms-switch-"` for E2E selectors. | | `name` | `string` | random uuid | FormGroup control name when bound via `[form]`. | -| `size` | `Size` (`'sm' \| 'md' \| 'lg'`) | `'md'` | Reserved; current template does not branch on this, so `size="sm"` does not compact switch UI yet. | +| `size` | `Size` (`'sm' \| 'md' \| 'lg'`) | `'md'` | Track, handle, icon and focus-layer sizing. `sm`: 36×20px; `md`: 52×32px; `lg`: 60×36px. Inside `sd-table`, the compact `sm` appearance takes precedence. | | `form` | `NgForm \| FormGroup \| undefined \| null` | `undefined` | Parent form. `NgForm` is auto-unwrapped to its inner `FormGroup`. | | `label` | `string \| undefined` | `undefined` | Label rendered to the right of the toggle (via ``). | | `color` | `Color` | `'primary'` | Material color for the ON state knob/track. | @@ -94,6 +94,16 @@ None — label comes from the `[label]` input. - Inline error message (``) appears below the row in red once the control is touched/dirty and `formControl.errors?.required` is set (i.e. `required` was set and the value is still `null`/`undefined`); suppressed when `[hideInlineError]="true"` - No outlined `mat-form-field` chrome — visually denser and lighter than `` / `` +## Sizes + +`size` can be a literal or a reactive binding. Changing it preserves the model, validators, disabled state and form registration. Read-only `viewed` text keeps its existing typography. On touch devices the interactive control retains a minimum 44px height. + +```html + + + +``` + ## Standalone imports and table-cell usage Every standalone host that uses `` must import `SdSwitch`. @@ -108,8 +118,8 @@ import { SdSwitch } from '@sdcorejs/angular/forms'; imports: [SdTable, SdTableCellDefDirective, SdSwitch], template: ` - - + + `, @@ -119,20 +129,20 @@ export class ActiveTableComponent { } ``` -`size="sm"` is currently reserved for future switch sizing and does not compact switch UI yet. In table cells, use `hideInlineError` and keep labels short or omit the label. +Switches rendered inside `sd-table` automatically use the compact `sm` appearance, including consumer cell templates and mobile cards. This table appearance takes precedence over the `size` input; outside tables the default remains `md`. Use `hideInlineError` when the cell must not grow to show inline validation, and provide a concise label. ```html - - + + ``` ## Dense dashboard/filter usage -When this control is rendered in dashboard cards, filter bars, external filter panels, table toolbars, query bars, or other compact non-form surfaces, prefer `hideInlineError` so Material does not reserve the inline error/subscript row under the field. Pair it with `size="sm"` when the component supports `size`. Validation remains visible through the compact error icon/tooltip without increasing the control height, and the message is also exposed to assistive tech through a screen-reader-only element (`span.sd-visually-hidden`) referenced by `aria-describedby`. +Use `size="sm"` in compact filter bars, dashboard cards and toolbars. Add `hideInlineError` only when inline validation feedback is shown elsewhere: it suppresses the message, while validation still runs. The switch does not render a replacement error icon or tooltip. ```html - + ``` ## Examples diff --git a/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.html b/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.html index cb2d63ff7..5ad7942af 100644 --- a/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.html +++ b/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.html @@ -1,6 +1,7 @@ +@let _label = label(); @if (isViewed()) { - @if (label()) { -
{{ label() }}
+ @if (_label) { +
{{ _label }}
}
{{ (model() ? 'core.form.switch.on' : 'core.form.switch.off') | sdTranslate }}
} @else { @@ -11,7 +12,7 @@ [attr.data-empty]="dataEmpty()" [attr.data-value]="dataValue()" [attr.data-required]="dataRequired()"> - + @let _errorMessage = visibleErrorMessage(); @if (_errorMessage) { diff --git a/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.scss b/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.scss index 8363e5d47..21064be56 100644 --- a/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.scss +++ b/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.scss @@ -1,4 +1,76 @@ +@use 'sass:math'; + +@mixin switch-size($width, $height, $handle, $off-handle, $pressed, $icon, $state-layer) { + --sd-switch-width: #{$width}; + --sd-switch-height: #{$height}; + --sd-switch-handle-size: #{$handle}; + --sd-switch-off-handle-size: #{$off-handle}; + --sd-switch-pressed-size: #{$pressed}; + --sd-switch-icon-size: #{$icon}; + --sd-switch-state-layer-size: #{$state-layer}; + --sd-switch-selected-margin: 0 #{$width - math.div($height + $handle, 2)}; + --sd-switch-unselected-margin: 0 #{math.div($height - $off-handle, 2)}; + --sd-switch-unselected-icon-margin: 0 #{math.div($height - $handle, 2)}; + --sd-switch-selected-pressed-margin: 0 #{$width - math.div($height + $pressed, 2)}; + --sd-switch-unselected-pressed-margin: 0 #{math.div($height - $pressed, 2)}; +} + +:host { + @include switch-size(52px, 32px, 24px, 16px, 28px, 16px, 40px); +} + +:host([data-size='lg']) { + @include switch-size(60px, 36px, 28px, 20px, 32px, 20px, 44px); +} + +// why: cell templates thuộc consumer nên dùng ngữ cảnh DOM để switch trong bảng luôn gọn, +// kể cả template được chiếu qua nhiều lớp component hoặc chuyển sang mobile card. +:host([data-size='sm']), +:host-context(sd-table)[data-size] { + @include switch-size(36px, 20px, 16px, 12px, 18px, 12px, 28px); +} + +:host ::ng-deep .mat-mdc-slide-toggle, +:host ::ng-deep .mdc-switch { + // why: v19 dùng mdc-switch/mat-switch, các major mới dùng mat-slide-toggle. + @each $prefix in mdc-switch, mat-slide-toggle { + --#{$prefix}-track-width: var(--sd-switch-width) !important; + --#{$prefix}-track-height: var(--sd-switch-height) !important; + --#{$prefix}-track-shape: 999px !important; + --#{$prefix}-state-layer-size: var(--sd-switch-state-layer-size) !important; + --#{$prefix}-selected-icon-size: var(--sd-switch-icon-size) !important; + --#{$prefix}-unselected-icon-size: var(--sd-switch-icon-size) !important; + } + @each $prefix in mat-switch, mat-slide-toggle { + --#{$prefix}-selected-handle-size: var(--sd-switch-handle-size) !important; + --#{$prefix}-unselected-handle-size: var(--sd-switch-off-handle-size) !important; + --#{$prefix}-with-icon-handle-size: var(--sd-switch-handle-size) !important; + --#{$prefix}-pressed-handle-size: var(--sd-switch-pressed-size) !important; + --#{$prefix}-selected-handle-horizontal-margin: var(--sd-switch-selected-margin) !important; + --#{$prefix}-selected-with-icon-handle-horizontal-margin: var(--sd-switch-selected-margin) !important; + --#{$prefix}-unselected-handle-horizontal-margin: var(--sd-switch-unselected-margin) !important; + --#{$prefix}-unselected-with-icon-handle-horizontal-margin: var(--sd-switch-unselected-icon-margin) !important; + --#{$prefix}-selected-pressed-handle-horizontal-margin: var(--sd-switch-selected-pressed-margin) !important; + --#{$prefix}-unselected-pressed-handle-horizontal-margin: var(--sd-switch-unselected-pressed-margin) !important; + } +} + :host ::ng-deep { + .mdc-switch { + min-height: max(28px, var(--sd-switch-height)); + } + + .mat-mdc-slide-toggle-touch-target { + height: max(28px, var(--sd-switch-height)); + } + + @media (pointer: coarse) { + .mdc-switch, + .mat-mdc-slide-toggle-touch-target { + min-height: 44px; + } + } + // NOTE: Bootstrap reboot adds margin-bottom to label; keep this override while Bootstrap is used. label { margin-bottom: 0 !important; @@ -13,11 +85,21 @@ --sd-switch-track-off-hover: var(--sd-border, #c4c6d0); --sd-switch-handle-off: var(--sd-text-secondary, #44474f); } -:host(.sd-c-secondary) { --sd-c: var(--sd-secondary); } -:host(.sd-c-info) { --sd-c: var(--sd-info); } -:host(.sd-c-success) { --sd-c: var(--sd-success); } -:host(.sd-c-warning) { --sd-c: var(--sd-warning); } -:host(.sd-c-error) { --sd-c: var(--sd-error); } +:host(.sd-c-secondary) { + --sd-c: var(--sd-secondary); +} +:host(.sd-c-info) { + --sd-c: var(--sd-info); +} +:host(.sd-c-success) { + --sd-c: var(--sd-success); +} +:host(.sd-c-warning) { + --sd-c: var(--sd-warning); +} +:host(.sd-c-error) { + --sd-c: var(--sd-error); +} // why: Angular Material exposes two switch token tiers across supported versions: // - `--mdc-switch-*` on `.mdc-switch` diff --git a/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts b/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts index 567089dc9..d1d680416 100644 --- a/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts +++ b/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts @@ -2,6 +2,7 @@ import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup, FormsModule, NgForm, ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/slide-toggle'; import { SdSwitch } from './switch.component'; @Component({ @@ -380,3 +381,97 @@ describe('SdSwitch (viewed inline mode)', () => { expect(fixture.nativeElement.querySelector('mat-slide-toggle')).toBeNull(); }); }); + +describe('SdSwitch sizes', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SdSwitch, NoopAnimationsModule], + providers: [{ provide: MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS, useValue: {} }], + }).compileComponents(); + fixture = TestBed.createComponent(SdSwitch); + fixture.componentRef.setInput('label', 'Notifications'); + }); + + it('keeps the default medium track at 52 × 32px', () => { + fixture.detectChanges(); + const track = fixture.nativeElement.querySelector('.mdc-switch__track') as HTMLElement; + expect(track.getBoundingClientRect().width).toBe(52); + expect(track.getBoundingClientRect().height).toBe(32); + expect(fixture.nativeElement.getAttribute('data-size')).toBe('md'); + }); + + for (const [size, width, height, handleSize] of [ + ['sm', 36, 20, 16], + ['md', 52, 32, 24], + ['lg', 60, 36, 28], + ] as const) { + it(`renders ${size} with centered handles in both states and preserves toggling/disabled behavior`, () => { + fixture.componentRef.setInput('size', size); + fixture.detectChanges(); + const change = spyOn(fixture.componentInstance.sdChange, 'emit').and.callThrough(); + const button = fixture.nativeElement.querySelector('button[role="switch"]') as HTMLButtonElement; + const track = fixture.nativeElement.querySelector('.mdc-switch__track') as HTMLElement; + const handle = fixture.nativeElement.querySelector('.mdc-switch__handle') as HTMLElement; + for (const checked of [false, true]) { + fixture.componentRef.setInput('model', checked); + fixture.detectChanges(); + const t = track.getBoundingClientRect(); + const h = handle.getBoundingClientRect(); + expect(t.width).toBe(width); + expect(t.height).toBe(height); + expect(h.width).toBe(handleSize); + expect(h.height).toBe(handleSize); + expect(h.top + h.height / 2).toBeCloseTo(t.top + t.height / 2, 0); + expect(h.left).toBeGreaterThanOrEqual(t.left); + expect(h.right).toBeLessThanOrEqual(t.right); + expect(button.getAttribute('aria-checked')).toBe(String(checked)); + } + change.calls.reset(); + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.model()).toBeFalse(); + expect(change).toHaveBeenCalledOnceWith(false); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + button.click(); + expect(change).toHaveBeenCalledTimes(1); + expect(track.getBoundingClientRect().width).toBe(width); + }); + } + + it('updates size dynamically without emitting a value change', () => { + fixture.detectChanges(); + const change = spyOn(fixture.componentInstance.sdChange, 'emit'); + for (const [size, width] of [ + ['sm', 36], + ['lg', 60], + ['md', 52], + ] as const) { + fixture.componentRef.setInput('size', size); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.mdc-switch__track').getBoundingClientRect().width).toBe(width); + } + expect(change).not.toHaveBeenCalled(); + }); + + it('keeps the small handle inside its track with hidden icons and RTL direction', async () => { + TestBed.inject(MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS).hideIcon = true; + fixture.componentRef.setInput('size', 'sm'); + fixture.detectChanges(); + fixture.nativeElement.setAttribute('dir', 'rtl'); + for (const checked of [false, true]) { + fixture.componentRef.setInput('model', checked); + fixture.detectChanges(); + const track = fixture.nativeElement.querySelector('.mdc-switch__track').getBoundingClientRect(); + const handleElement = fixture.nativeElement.querySelector('.mdc-switch__handle') as HTMLElement; + // why: Material vẫn transition kích thước núm gạt khi tắt icon dù dùng NoopAnimationsModule. + await Promise.all(handleElement.getAnimations().map(animation => animation.finished)); + const handle = handleElement.getBoundingClientRect(); + expect(handle.width).toBe(checked ? 16 : 12); + expect(handle.left).toBeGreaterThanOrEqual(track.left); + expect(handle.right).toBeLessThanOrEqual(track.right); + } + }); +}); diff --git a/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.ts b/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.ts index 3e6db59f4..d4c46a188 100644 --- a/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.ts +++ b/versions/v19/projects/sdcorejs-angular/forms/switch/src/switch.component.ts @@ -29,7 +29,7 @@ import { ɵSdFormControlParent, } from '@sdcorejs/angular/forms/models'; import { sdIsEmpty, sdSerializeDataValue } from '@sdcorejs/angular/utilities/data-state'; -import { Color } from '@sdcorejs/utils/models'; +import { Color, Size } from '@sdcorejs/utils/models'; import { Subscription } from 'rxjs'; @Component({ @@ -38,6 +38,7 @@ import { Subscription } from 'rxjs'; styleUrl: './switch.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { + '[attr.data-size]': 'size()', // why: dùng host class .sd-c- (thay vì data-attr) để reactivity host-binding với // signal chắc chắn áp dụng + có default fallback `sd-c-primary` khi color() là 'primary'. // Test cũ assert data-sd-color đã được thay bằng class assert tương ứng. @@ -71,6 +72,7 @@ export class SdSwitch implements OnInit, OnDestroy { readonly label = input(undefined, { transform: (v): string | undefined => v ?? undefined, }); + readonly size = input('md'); // why: legacy callers pass `null` to mean "fallback to primary" — keep that contract readonly color = input('primary', { transform: (v): Color => v || 'primary', diff --git a/versions/v20/SYNC-STATUS.md b/versions/v20/SYNC-STATUS.md index c16807cfd..ad186732a 100644 --- a/versions/v20/SYNC-STATUS.md +++ b/versions/v20/SYNC-STATUS.md @@ -4,7 +4,7 @@ |-----|-------| | Angular Major | 20 | | Legacy Source Commit | d12478a1 | -| Updated At | 2026-09-11 15:55:24 | +| Updated At | 2026-09-11 16:48:14 | | Origin | repo-owned versions/v19 (final legacy sync vn-angular@d12478a1) | | Workspace Flow | versions/v19 → v20 | | Development Mode | repo-owned independent pack | diff --git a/versions/v20/projects/sdcorejs-angular/components/table/sd-table.md b/versions/v20/projects/sdcorejs-angular/components/table/sd-table.md index bd8134c44..986508fa8 100644 --- a/versions/v20/projects/sdcorejs-angular/components/table/sd-table.md +++ b/versions/v20/projects/sdcorejs-angular/components/table/sd-table.md @@ -425,6 +425,10 @@ command: { } ``` +### Switches in cells + +Switches (`sd-switch`) rendered within the table use the compact `sm` appearance (36×20px track), including projected consumer cell templates and mobile cards. This table styling takes precedence over the switch size; switches outside the table still default to `md`. + ## Inputs (the host element) | Name | Type | Default | Notes | diff --git a/versions/v20/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts b/versions/v20/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts new file mode 100644 index 000000000..4f0edb638 --- /dev/null +++ b/versions/v20/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SdSwitch } from '@sdcorejs/angular/forms/switch'; +import { SdTable } from './table.component'; +import { SdTableCellDefDirective } from './directives/sd-table-cell-def.directive'; +import { SdTableOption } from './models/table-option.model'; + +@Component({ + imports: [SdTable, SdTableCellDefDirective, SdSwitch], + template: ` + + + + + + + + `, +}) +class SwitchTableHost { + row = { id: 1, active: false }; + option: SdTableOption = { + type: 'local', + items: () => [this.row], + columns: [{ field: 'active', title: 'Active', type: 'boolean' }], + }; +} + +describe('SdTable switch sizing', () => { + it('compacts a consumer cell switch to sm without affecting an outside switch or its model binding', fakeAsync(() => { + TestBed.configureTestingModule({ imports: [SwitchTableHost, NoopAnimationsModule] }); + const fixture = TestBed.createComponent(SwitchTableHost); + fixture.detectChanges(); + tick(800); + flush(); + fixture.detectChanges(); + const inside = fixture.nativeElement.querySelector('sd-table sd-switch') as HTMLElement; + const outside = fixture.nativeElement.querySelector('sd-switch') as HTMLElement; + expect(inside).not.toBeNull(); + expect(inside.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(36); + expect(inside.querySelector('.mdc-switch__track')!.getBoundingClientRect().height).toBe(20); + const explicitLarge = fixture.nativeElement.querySelector('sd-table sd-switch[size="lg"]') as HTMLElement; + expect(explicitLarge.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(36); + expect(outside.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(52); + inside.querySelector('button[role="switch"]')!.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.row.active).toBeTrue(); + fixture.destroy(); + flush(); + })); +}); diff --git a/versions/v20/projects/sdcorejs-angular/forms/switch/sd-switch.md b/versions/v20/projects/sdcorejs-angular/forms/switch/sd-switch.md index a8f287721..01e6b2c4e 100644 --- a/versions/v20/projects/sdcorejs-angular/forms/switch/sd-switch.md +++ b/versions/v20/projects/sdcorejs-angular/forms/switch/sd-switch.md @@ -30,7 +30,7 @@ iOS-style toggle switch — boolean ON/OFF in a single tap. Use for feature flag | ----------------- | ------------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `autoId` | `string \| null \| undefined` | `undefined` | Generates `data-autoId="forms-switch-"` for E2E selectors. | | `name` | `string` | random uuid | FormGroup control name when bound via `[form]`. | -| `size` | `Size` (`'sm' \| 'md' \| 'lg'`) | `'md'` | Reserved; current template does not branch on this, so `size="sm"` does not compact switch UI yet. | +| `size` | `Size` (`'sm' \| 'md' \| 'lg'`) | `'md'` | Track, handle, icon and focus-layer sizing. `sm`: 36×20px; `md`: 52×32px; `lg`: 60×36px. Inside `sd-table`, the compact `sm` appearance takes precedence. | | `form` | `NgForm \| FormGroup \| undefined \| null` | `undefined` | Parent form. `NgForm` is auto-unwrapped to its inner `FormGroup`. | | `label` | `string \| undefined` | `undefined` | Label rendered to the right of the toggle (via ``). | | `color` | `Color` | `'primary'` | Material color for the ON state knob/track. | @@ -94,6 +94,16 @@ None — label comes from the `[label]` input. - Inline error message (``) appears below the row in red once the control is touched/dirty and `formControl.errors?.required` is set (i.e. `required` was set and the value is still `null`/`undefined`); suppressed when `[hideInlineError]="true"` - No outlined `mat-form-field` chrome — visually denser and lighter than `` / `` +## Sizes + +`size` can be a literal or a reactive binding. Changing it preserves the model, validators, disabled state and form registration. Read-only `viewed` text keeps its existing typography. On touch devices the interactive control retains a minimum 44px height. + +```html + + + +``` + ## Standalone imports and table-cell usage Every standalone host that uses `` must import `SdSwitch`. @@ -108,8 +118,8 @@ import { SdSwitch } from '@sdcorejs/angular/forms'; imports: [SdTable, SdTableCellDefDirective, SdSwitch], template: ` - - + + `, @@ -119,20 +129,20 @@ export class ActiveTableComponent { } ``` -`size="sm"` is currently reserved for future switch sizing and does not compact switch UI yet. In table cells, use `hideInlineError` and keep labels short or omit the label. +Switches rendered inside `sd-table` automatically use the compact `sm` appearance, including consumer cell templates and mobile cards. This table appearance takes precedence over the `size` input; outside tables the default remains `md`. Use `hideInlineError` when the cell must not grow to show inline validation, and provide a concise label. ```html - - + + ``` ## Dense dashboard/filter usage -When this control is rendered in dashboard cards, filter bars, external filter panels, table toolbars, query bars, or other compact non-form surfaces, prefer `hideInlineError` so Material does not reserve the inline error/subscript row under the field. Pair it with `size="sm"` when the component supports `size`. Validation remains visible through the compact error icon/tooltip without increasing the control height, and the message is also exposed to assistive tech through a screen-reader-only element (`span.sd-visually-hidden`) referenced by `aria-describedby`. +Use `size="sm"` in compact filter bars, dashboard cards and toolbars. Add `hideInlineError` only when inline validation feedback is shown elsewhere: it suppresses the message, while validation still runs. The switch does not render a replacement error icon or tooltip. ```html - + ``` ## Examples diff --git a/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.html b/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.html index cb2d63ff7..5ad7942af 100644 --- a/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.html +++ b/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.html @@ -1,6 +1,7 @@ +@let _label = label(); @if (isViewed()) { - @if (label()) { -
{{ label() }}
+ @if (_label) { +
{{ _label }}
}
{{ (model() ? 'core.form.switch.on' : 'core.form.switch.off') | sdTranslate }}
} @else { @@ -11,7 +12,7 @@ [attr.data-empty]="dataEmpty()" [attr.data-value]="dataValue()" [attr.data-required]="dataRequired()"> - + @let _errorMessage = visibleErrorMessage(); @if (_errorMessage) { diff --git a/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.scss b/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.scss index 8363e5d47..21064be56 100644 --- a/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.scss +++ b/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.scss @@ -1,4 +1,76 @@ +@use 'sass:math'; + +@mixin switch-size($width, $height, $handle, $off-handle, $pressed, $icon, $state-layer) { + --sd-switch-width: #{$width}; + --sd-switch-height: #{$height}; + --sd-switch-handle-size: #{$handle}; + --sd-switch-off-handle-size: #{$off-handle}; + --sd-switch-pressed-size: #{$pressed}; + --sd-switch-icon-size: #{$icon}; + --sd-switch-state-layer-size: #{$state-layer}; + --sd-switch-selected-margin: 0 #{$width - math.div($height + $handle, 2)}; + --sd-switch-unselected-margin: 0 #{math.div($height - $off-handle, 2)}; + --sd-switch-unselected-icon-margin: 0 #{math.div($height - $handle, 2)}; + --sd-switch-selected-pressed-margin: 0 #{$width - math.div($height + $pressed, 2)}; + --sd-switch-unselected-pressed-margin: 0 #{math.div($height - $pressed, 2)}; +} + +:host { + @include switch-size(52px, 32px, 24px, 16px, 28px, 16px, 40px); +} + +:host([data-size='lg']) { + @include switch-size(60px, 36px, 28px, 20px, 32px, 20px, 44px); +} + +// why: cell templates thuộc consumer nên dùng ngữ cảnh DOM để switch trong bảng luôn gọn, +// kể cả template được chiếu qua nhiều lớp component hoặc chuyển sang mobile card. +:host([data-size='sm']), +:host-context(sd-table)[data-size] { + @include switch-size(36px, 20px, 16px, 12px, 18px, 12px, 28px); +} + +:host ::ng-deep .mat-mdc-slide-toggle, +:host ::ng-deep .mdc-switch { + // why: v19 dùng mdc-switch/mat-switch, các major mới dùng mat-slide-toggle. + @each $prefix in mdc-switch, mat-slide-toggle { + --#{$prefix}-track-width: var(--sd-switch-width) !important; + --#{$prefix}-track-height: var(--sd-switch-height) !important; + --#{$prefix}-track-shape: 999px !important; + --#{$prefix}-state-layer-size: var(--sd-switch-state-layer-size) !important; + --#{$prefix}-selected-icon-size: var(--sd-switch-icon-size) !important; + --#{$prefix}-unselected-icon-size: var(--sd-switch-icon-size) !important; + } + @each $prefix in mat-switch, mat-slide-toggle { + --#{$prefix}-selected-handle-size: var(--sd-switch-handle-size) !important; + --#{$prefix}-unselected-handle-size: var(--sd-switch-off-handle-size) !important; + --#{$prefix}-with-icon-handle-size: var(--sd-switch-handle-size) !important; + --#{$prefix}-pressed-handle-size: var(--sd-switch-pressed-size) !important; + --#{$prefix}-selected-handle-horizontal-margin: var(--sd-switch-selected-margin) !important; + --#{$prefix}-selected-with-icon-handle-horizontal-margin: var(--sd-switch-selected-margin) !important; + --#{$prefix}-unselected-handle-horizontal-margin: var(--sd-switch-unselected-margin) !important; + --#{$prefix}-unselected-with-icon-handle-horizontal-margin: var(--sd-switch-unselected-icon-margin) !important; + --#{$prefix}-selected-pressed-handle-horizontal-margin: var(--sd-switch-selected-pressed-margin) !important; + --#{$prefix}-unselected-pressed-handle-horizontal-margin: var(--sd-switch-unselected-pressed-margin) !important; + } +} + :host ::ng-deep { + .mdc-switch { + min-height: max(28px, var(--sd-switch-height)); + } + + .mat-mdc-slide-toggle-touch-target { + height: max(28px, var(--sd-switch-height)); + } + + @media (pointer: coarse) { + .mdc-switch, + .mat-mdc-slide-toggle-touch-target { + min-height: 44px; + } + } + // NOTE: Bootstrap reboot adds margin-bottom to label; keep this override while Bootstrap is used. label { margin-bottom: 0 !important; @@ -13,11 +85,21 @@ --sd-switch-track-off-hover: var(--sd-border, #c4c6d0); --sd-switch-handle-off: var(--sd-text-secondary, #44474f); } -:host(.sd-c-secondary) { --sd-c: var(--sd-secondary); } -:host(.sd-c-info) { --sd-c: var(--sd-info); } -:host(.sd-c-success) { --sd-c: var(--sd-success); } -:host(.sd-c-warning) { --sd-c: var(--sd-warning); } -:host(.sd-c-error) { --sd-c: var(--sd-error); } +:host(.sd-c-secondary) { + --sd-c: var(--sd-secondary); +} +:host(.sd-c-info) { + --sd-c: var(--sd-info); +} +:host(.sd-c-success) { + --sd-c: var(--sd-success); +} +:host(.sd-c-warning) { + --sd-c: var(--sd-warning); +} +:host(.sd-c-error) { + --sd-c: var(--sd-error); +} // why: Angular Material exposes two switch token tiers across supported versions: // - `--mdc-switch-*` on `.mdc-switch` diff --git a/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts b/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts index 567089dc9..d1d680416 100644 --- a/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts +++ b/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts @@ -2,6 +2,7 @@ import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup, FormsModule, NgForm, ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/slide-toggle'; import { SdSwitch } from './switch.component'; @Component({ @@ -380,3 +381,97 @@ describe('SdSwitch (viewed inline mode)', () => { expect(fixture.nativeElement.querySelector('mat-slide-toggle')).toBeNull(); }); }); + +describe('SdSwitch sizes', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SdSwitch, NoopAnimationsModule], + providers: [{ provide: MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS, useValue: {} }], + }).compileComponents(); + fixture = TestBed.createComponent(SdSwitch); + fixture.componentRef.setInput('label', 'Notifications'); + }); + + it('keeps the default medium track at 52 × 32px', () => { + fixture.detectChanges(); + const track = fixture.nativeElement.querySelector('.mdc-switch__track') as HTMLElement; + expect(track.getBoundingClientRect().width).toBe(52); + expect(track.getBoundingClientRect().height).toBe(32); + expect(fixture.nativeElement.getAttribute('data-size')).toBe('md'); + }); + + for (const [size, width, height, handleSize] of [ + ['sm', 36, 20, 16], + ['md', 52, 32, 24], + ['lg', 60, 36, 28], + ] as const) { + it(`renders ${size} with centered handles in both states and preserves toggling/disabled behavior`, () => { + fixture.componentRef.setInput('size', size); + fixture.detectChanges(); + const change = spyOn(fixture.componentInstance.sdChange, 'emit').and.callThrough(); + const button = fixture.nativeElement.querySelector('button[role="switch"]') as HTMLButtonElement; + const track = fixture.nativeElement.querySelector('.mdc-switch__track') as HTMLElement; + const handle = fixture.nativeElement.querySelector('.mdc-switch__handle') as HTMLElement; + for (const checked of [false, true]) { + fixture.componentRef.setInput('model', checked); + fixture.detectChanges(); + const t = track.getBoundingClientRect(); + const h = handle.getBoundingClientRect(); + expect(t.width).toBe(width); + expect(t.height).toBe(height); + expect(h.width).toBe(handleSize); + expect(h.height).toBe(handleSize); + expect(h.top + h.height / 2).toBeCloseTo(t.top + t.height / 2, 0); + expect(h.left).toBeGreaterThanOrEqual(t.left); + expect(h.right).toBeLessThanOrEqual(t.right); + expect(button.getAttribute('aria-checked')).toBe(String(checked)); + } + change.calls.reset(); + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.model()).toBeFalse(); + expect(change).toHaveBeenCalledOnceWith(false); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + button.click(); + expect(change).toHaveBeenCalledTimes(1); + expect(track.getBoundingClientRect().width).toBe(width); + }); + } + + it('updates size dynamically without emitting a value change', () => { + fixture.detectChanges(); + const change = spyOn(fixture.componentInstance.sdChange, 'emit'); + for (const [size, width] of [ + ['sm', 36], + ['lg', 60], + ['md', 52], + ] as const) { + fixture.componentRef.setInput('size', size); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.mdc-switch__track').getBoundingClientRect().width).toBe(width); + } + expect(change).not.toHaveBeenCalled(); + }); + + it('keeps the small handle inside its track with hidden icons and RTL direction', async () => { + TestBed.inject(MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS).hideIcon = true; + fixture.componentRef.setInput('size', 'sm'); + fixture.detectChanges(); + fixture.nativeElement.setAttribute('dir', 'rtl'); + for (const checked of [false, true]) { + fixture.componentRef.setInput('model', checked); + fixture.detectChanges(); + const track = fixture.nativeElement.querySelector('.mdc-switch__track').getBoundingClientRect(); + const handleElement = fixture.nativeElement.querySelector('.mdc-switch__handle') as HTMLElement; + // why: Material vẫn transition kích thước núm gạt khi tắt icon dù dùng NoopAnimationsModule. + await Promise.all(handleElement.getAnimations().map(animation => animation.finished)); + const handle = handleElement.getBoundingClientRect(); + expect(handle.width).toBe(checked ? 16 : 12); + expect(handle.left).toBeGreaterThanOrEqual(track.left); + expect(handle.right).toBeLessThanOrEqual(track.right); + } + }); +}); diff --git a/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.ts b/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.ts index 3e6db59f4..d4c46a188 100644 --- a/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.ts +++ b/versions/v20/projects/sdcorejs-angular/forms/switch/src/switch.component.ts @@ -29,7 +29,7 @@ import { ɵSdFormControlParent, } from '@sdcorejs/angular/forms/models'; import { sdIsEmpty, sdSerializeDataValue } from '@sdcorejs/angular/utilities/data-state'; -import { Color } from '@sdcorejs/utils/models'; +import { Color, Size } from '@sdcorejs/utils/models'; import { Subscription } from 'rxjs'; @Component({ @@ -38,6 +38,7 @@ import { Subscription } from 'rxjs'; styleUrl: './switch.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { + '[attr.data-size]': 'size()', // why: dùng host class .sd-c- (thay vì data-attr) để reactivity host-binding với // signal chắc chắn áp dụng + có default fallback `sd-c-primary` khi color() là 'primary'. // Test cũ assert data-sd-color đã được thay bằng class assert tương ứng. @@ -71,6 +72,7 @@ export class SdSwitch implements OnInit, OnDestroy { readonly label = input(undefined, { transform: (v): string | undefined => v ?? undefined, }); + readonly size = input('md'); // why: legacy callers pass `null` to mean "fallback to primary" — keep that contract readonly color = input('primary', { transform: (v): Color => v || 'primary', diff --git a/versions/v21/SYNC-STATUS.md b/versions/v21/SYNC-STATUS.md index 4db35b2c8..7a81080f8 100644 --- a/versions/v21/SYNC-STATUS.md +++ b/versions/v21/SYNC-STATUS.md @@ -4,7 +4,7 @@ |-----|-------| | Angular Major | 21 | | Legacy Source Commit | d12478a1 | -| Updated At | 2026-09-11 15:55:24 | +| Updated At | 2026-09-11 16:48:14 | | Origin | repo-owned versions/v19 (final legacy sync vn-angular@d12478a1) | | Workspace Flow | versions/v19 → v21 | | Development Mode | repo-owned independent pack | diff --git a/versions/v21/projects/sdcorejs-angular/components/table/sd-table.md b/versions/v21/projects/sdcorejs-angular/components/table/sd-table.md index bd8134c44..986508fa8 100644 --- a/versions/v21/projects/sdcorejs-angular/components/table/sd-table.md +++ b/versions/v21/projects/sdcorejs-angular/components/table/sd-table.md @@ -425,6 +425,10 @@ command: { } ``` +### Switches in cells + +Switches (`sd-switch`) rendered within the table use the compact `sm` appearance (36×20px track), including projected consumer cell templates and mobile cards. This table styling takes precedence over the switch size; switches outside the table still default to `md`. + ## Inputs (the host element) | Name | Type | Default | Notes | diff --git a/versions/v21/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts b/versions/v21/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts new file mode 100644 index 000000000..4f0edb638 --- /dev/null +++ b/versions/v21/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SdSwitch } from '@sdcorejs/angular/forms/switch'; +import { SdTable } from './table.component'; +import { SdTableCellDefDirective } from './directives/sd-table-cell-def.directive'; +import { SdTableOption } from './models/table-option.model'; + +@Component({ + imports: [SdTable, SdTableCellDefDirective, SdSwitch], + template: ` + + + + + + + + `, +}) +class SwitchTableHost { + row = { id: 1, active: false }; + option: SdTableOption = { + type: 'local', + items: () => [this.row], + columns: [{ field: 'active', title: 'Active', type: 'boolean' }], + }; +} + +describe('SdTable switch sizing', () => { + it('compacts a consumer cell switch to sm without affecting an outside switch or its model binding', fakeAsync(() => { + TestBed.configureTestingModule({ imports: [SwitchTableHost, NoopAnimationsModule] }); + const fixture = TestBed.createComponent(SwitchTableHost); + fixture.detectChanges(); + tick(800); + flush(); + fixture.detectChanges(); + const inside = fixture.nativeElement.querySelector('sd-table sd-switch') as HTMLElement; + const outside = fixture.nativeElement.querySelector('sd-switch') as HTMLElement; + expect(inside).not.toBeNull(); + expect(inside.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(36); + expect(inside.querySelector('.mdc-switch__track')!.getBoundingClientRect().height).toBe(20); + const explicitLarge = fixture.nativeElement.querySelector('sd-table sd-switch[size="lg"]') as HTMLElement; + expect(explicitLarge.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(36); + expect(outside.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(52); + inside.querySelector('button[role="switch"]')!.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.row.active).toBeTrue(); + fixture.destroy(); + flush(); + })); +}); diff --git a/versions/v21/projects/sdcorejs-angular/forms/switch/sd-switch.md b/versions/v21/projects/sdcorejs-angular/forms/switch/sd-switch.md index a8f287721..01e6b2c4e 100644 --- a/versions/v21/projects/sdcorejs-angular/forms/switch/sd-switch.md +++ b/versions/v21/projects/sdcorejs-angular/forms/switch/sd-switch.md @@ -30,7 +30,7 @@ iOS-style toggle switch — boolean ON/OFF in a single tap. Use for feature flag | ----------------- | ------------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `autoId` | `string \| null \| undefined` | `undefined` | Generates `data-autoId="forms-switch-"` for E2E selectors. | | `name` | `string` | random uuid | FormGroup control name when bound via `[form]`. | -| `size` | `Size` (`'sm' \| 'md' \| 'lg'`) | `'md'` | Reserved; current template does not branch on this, so `size="sm"` does not compact switch UI yet. | +| `size` | `Size` (`'sm' \| 'md' \| 'lg'`) | `'md'` | Track, handle, icon and focus-layer sizing. `sm`: 36×20px; `md`: 52×32px; `lg`: 60×36px. Inside `sd-table`, the compact `sm` appearance takes precedence. | | `form` | `NgForm \| FormGroup \| undefined \| null` | `undefined` | Parent form. `NgForm` is auto-unwrapped to its inner `FormGroup`. | | `label` | `string \| undefined` | `undefined` | Label rendered to the right of the toggle (via ``). | | `color` | `Color` | `'primary'` | Material color for the ON state knob/track. | @@ -94,6 +94,16 @@ None — label comes from the `[label]` input. - Inline error message (``) appears below the row in red once the control is touched/dirty and `formControl.errors?.required` is set (i.e. `required` was set and the value is still `null`/`undefined`); suppressed when `[hideInlineError]="true"` - No outlined `mat-form-field` chrome — visually denser and lighter than `` / `` +## Sizes + +`size` can be a literal or a reactive binding. Changing it preserves the model, validators, disabled state and form registration. Read-only `viewed` text keeps its existing typography. On touch devices the interactive control retains a minimum 44px height. + +```html + + + +``` + ## Standalone imports and table-cell usage Every standalone host that uses `` must import `SdSwitch`. @@ -108,8 +118,8 @@ import { SdSwitch } from '@sdcorejs/angular/forms'; imports: [SdTable, SdTableCellDefDirective, SdSwitch], template: ` - - + + `, @@ -119,20 +129,20 @@ export class ActiveTableComponent { } ``` -`size="sm"` is currently reserved for future switch sizing and does not compact switch UI yet. In table cells, use `hideInlineError` and keep labels short or omit the label. +Switches rendered inside `sd-table` automatically use the compact `sm` appearance, including consumer cell templates and mobile cards. This table appearance takes precedence over the `size` input; outside tables the default remains `md`. Use `hideInlineError` when the cell must not grow to show inline validation, and provide a concise label. ```html - - + + ``` ## Dense dashboard/filter usage -When this control is rendered in dashboard cards, filter bars, external filter panels, table toolbars, query bars, or other compact non-form surfaces, prefer `hideInlineError` so Material does not reserve the inline error/subscript row under the field. Pair it with `size="sm"` when the component supports `size`. Validation remains visible through the compact error icon/tooltip without increasing the control height, and the message is also exposed to assistive tech through a screen-reader-only element (`span.sd-visually-hidden`) referenced by `aria-describedby`. +Use `size="sm"` in compact filter bars, dashboard cards and toolbars. Add `hideInlineError` only when inline validation feedback is shown elsewhere: it suppresses the message, while validation still runs. The switch does not render a replacement error icon or tooltip. ```html - + ``` ## Examples diff --git a/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.html b/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.html index cb2d63ff7..5ad7942af 100644 --- a/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.html +++ b/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.html @@ -1,6 +1,7 @@ +@let _label = label(); @if (isViewed()) { - @if (label()) { -
{{ label() }}
+ @if (_label) { +
{{ _label }}
}
{{ (model() ? 'core.form.switch.on' : 'core.form.switch.off') | sdTranslate }}
} @else { @@ -11,7 +12,7 @@ [attr.data-empty]="dataEmpty()" [attr.data-value]="dataValue()" [attr.data-required]="dataRequired()"> - + @let _errorMessage = visibleErrorMessage(); @if (_errorMessage) { diff --git a/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.scss b/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.scss index 8363e5d47..21064be56 100644 --- a/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.scss +++ b/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.scss @@ -1,4 +1,76 @@ +@use 'sass:math'; + +@mixin switch-size($width, $height, $handle, $off-handle, $pressed, $icon, $state-layer) { + --sd-switch-width: #{$width}; + --sd-switch-height: #{$height}; + --sd-switch-handle-size: #{$handle}; + --sd-switch-off-handle-size: #{$off-handle}; + --sd-switch-pressed-size: #{$pressed}; + --sd-switch-icon-size: #{$icon}; + --sd-switch-state-layer-size: #{$state-layer}; + --sd-switch-selected-margin: 0 #{$width - math.div($height + $handle, 2)}; + --sd-switch-unselected-margin: 0 #{math.div($height - $off-handle, 2)}; + --sd-switch-unselected-icon-margin: 0 #{math.div($height - $handle, 2)}; + --sd-switch-selected-pressed-margin: 0 #{$width - math.div($height + $pressed, 2)}; + --sd-switch-unselected-pressed-margin: 0 #{math.div($height - $pressed, 2)}; +} + +:host { + @include switch-size(52px, 32px, 24px, 16px, 28px, 16px, 40px); +} + +:host([data-size='lg']) { + @include switch-size(60px, 36px, 28px, 20px, 32px, 20px, 44px); +} + +// why: cell templates thuộc consumer nên dùng ngữ cảnh DOM để switch trong bảng luôn gọn, +// kể cả template được chiếu qua nhiều lớp component hoặc chuyển sang mobile card. +:host([data-size='sm']), +:host-context(sd-table)[data-size] { + @include switch-size(36px, 20px, 16px, 12px, 18px, 12px, 28px); +} + +:host ::ng-deep .mat-mdc-slide-toggle, +:host ::ng-deep .mdc-switch { + // why: v19 dùng mdc-switch/mat-switch, các major mới dùng mat-slide-toggle. + @each $prefix in mdc-switch, mat-slide-toggle { + --#{$prefix}-track-width: var(--sd-switch-width) !important; + --#{$prefix}-track-height: var(--sd-switch-height) !important; + --#{$prefix}-track-shape: 999px !important; + --#{$prefix}-state-layer-size: var(--sd-switch-state-layer-size) !important; + --#{$prefix}-selected-icon-size: var(--sd-switch-icon-size) !important; + --#{$prefix}-unselected-icon-size: var(--sd-switch-icon-size) !important; + } + @each $prefix in mat-switch, mat-slide-toggle { + --#{$prefix}-selected-handle-size: var(--sd-switch-handle-size) !important; + --#{$prefix}-unselected-handle-size: var(--sd-switch-off-handle-size) !important; + --#{$prefix}-with-icon-handle-size: var(--sd-switch-handle-size) !important; + --#{$prefix}-pressed-handle-size: var(--sd-switch-pressed-size) !important; + --#{$prefix}-selected-handle-horizontal-margin: var(--sd-switch-selected-margin) !important; + --#{$prefix}-selected-with-icon-handle-horizontal-margin: var(--sd-switch-selected-margin) !important; + --#{$prefix}-unselected-handle-horizontal-margin: var(--sd-switch-unselected-margin) !important; + --#{$prefix}-unselected-with-icon-handle-horizontal-margin: var(--sd-switch-unselected-icon-margin) !important; + --#{$prefix}-selected-pressed-handle-horizontal-margin: var(--sd-switch-selected-pressed-margin) !important; + --#{$prefix}-unselected-pressed-handle-horizontal-margin: var(--sd-switch-unselected-pressed-margin) !important; + } +} + :host ::ng-deep { + .mdc-switch { + min-height: max(28px, var(--sd-switch-height)); + } + + .mat-mdc-slide-toggle-touch-target { + height: max(28px, var(--sd-switch-height)); + } + + @media (pointer: coarse) { + .mdc-switch, + .mat-mdc-slide-toggle-touch-target { + min-height: 44px; + } + } + // NOTE: Bootstrap reboot adds margin-bottom to label; keep this override while Bootstrap is used. label { margin-bottom: 0 !important; @@ -13,11 +85,21 @@ --sd-switch-track-off-hover: var(--sd-border, #c4c6d0); --sd-switch-handle-off: var(--sd-text-secondary, #44474f); } -:host(.sd-c-secondary) { --sd-c: var(--sd-secondary); } -:host(.sd-c-info) { --sd-c: var(--sd-info); } -:host(.sd-c-success) { --sd-c: var(--sd-success); } -:host(.sd-c-warning) { --sd-c: var(--sd-warning); } -:host(.sd-c-error) { --sd-c: var(--sd-error); } +:host(.sd-c-secondary) { + --sd-c: var(--sd-secondary); +} +:host(.sd-c-info) { + --sd-c: var(--sd-info); +} +:host(.sd-c-success) { + --sd-c: var(--sd-success); +} +:host(.sd-c-warning) { + --sd-c: var(--sd-warning); +} +:host(.sd-c-error) { + --sd-c: var(--sd-error); +} // why: Angular Material exposes two switch token tiers across supported versions: // - `--mdc-switch-*` on `.mdc-switch` diff --git a/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts b/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts index 567089dc9..d1d680416 100644 --- a/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts +++ b/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts @@ -2,6 +2,7 @@ import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup, FormsModule, NgForm, ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/slide-toggle'; import { SdSwitch } from './switch.component'; @Component({ @@ -380,3 +381,97 @@ describe('SdSwitch (viewed inline mode)', () => { expect(fixture.nativeElement.querySelector('mat-slide-toggle')).toBeNull(); }); }); + +describe('SdSwitch sizes', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SdSwitch, NoopAnimationsModule], + providers: [{ provide: MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS, useValue: {} }], + }).compileComponents(); + fixture = TestBed.createComponent(SdSwitch); + fixture.componentRef.setInput('label', 'Notifications'); + }); + + it('keeps the default medium track at 52 × 32px', () => { + fixture.detectChanges(); + const track = fixture.nativeElement.querySelector('.mdc-switch__track') as HTMLElement; + expect(track.getBoundingClientRect().width).toBe(52); + expect(track.getBoundingClientRect().height).toBe(32); + expect(fixture.nativeElement.getAttribute('data-size')).toBe('md'); + }); + + for (const [size, width, height, handleSize] of [ + ['sm', 36, 20, 16], + ['md', 52, 32, 24], + ['lg', 60, 36, 28], + ] as const) { + it(`renders ${size} with centered handles in both states and preserves toggling/disabled behavior`, () => { + fixture.componentRef.setInput('size', size); + fixture.detectChanges(); + const change = spyOn(fixture.componentInstance.sdChange, 'emit').and.callThrough(); + const button = fixture.nativeElement.querySelector('button[role="switch"]') as HTMLButtonElement; + const track = fixture.nativeElement.querySelector('.mdc-switch__track') as HTMLElement; + const handle = fixture.nativeElement.querySelector('.mdc-switch__handle') as HTMLElement; + for (const checked of [false, true]) { + fixture.componentRef.setInput('model', checked); + fixture.detectChanges(); + const t = track.getBoundingClientRect(); + const h = handle.getBoundingClientRect(); + expect(t.width).toBe(width); + expect(t.height).toBe(height); + expect(h.width).toBe(handleSize); + expect(h.height).toBe(handleSize); + expect(h.top + h.height / 2).toBeCloseTo(t.top + t.height / 2, 0); + expect(h.left).toBeGreaterThanOrEqual(t.left); + expect(h.right).toBeLessThanOrEqual(t.right); + expect(button.getAttribute('aria-checked')).toBe(String(checked)); + } + change.calls.reset(); + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.model()).toBeFalse(); + expect(change).toHaveBeenCalledOnceWith(false); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + button.click(); + expect(change).toHaveBeenCalledTimes(1); + expect(track.getBoundingClientRect().width).toBe(width); + }); + } + + it('updates size dynamically without emitting a value change', () => { + fixture.detectChanges(); + const change = spyOn(fixture.componentInstance.sdChange, 'emit'); + for (const [size, width] of [ + ['sm', 36], + ['lg', 60], + ['md', 52], + ] as const) { + fixture.componentRef.setInput('size', size); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.mdc-switch__track').getBoundingClientRect().width).toBe(width); + } + expect(change).not.toHaveBeenCalled(); + }); + + it('keeps the small handle inside its track with hidden icons and RTL direction', async () => { + TestBed.inject(MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS).hideIcon = true; + fixture.componentRef.setInput('size', 'sm'); + fixture.detectChanges(); + fixture.nativeElement.setAttribute('dir', 'rtl'); + for (const checked of [false, true]) { + fixture.componentRef.setInput('model', checked); + fixture.detectChanges(); + const track = fixture.nativeElement.querySelector('.mdc-switch__track').getBoundingClientRect(); + const handleElement = fixture.nativeElement.querySelector('.mdc-switch__handle') as HTMLElement; + // why: Material vẫn transition kích thước núm gạt khi tắt icon dù dùng NoopAnimationsModule. + await Promise.all(handleElement.getAnimations().map(animation => animation.finished)); + const handle = handleElement.getBoundingClientRect(); + expect(handle.width).toBe(checked ? 16 : 12); + expect(handle.left).toBeGreaterThanOrEqual(track.left); + expect(handle.right).toBeLessThanOrEqual(track.right); + } + }); +}); diff --git a/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.ts b/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.ts index 3e6db59f4..d4c46a188 100644 --- a/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.ts +++ b/versions/v21/projects/sdcorejs-angular/forms/switch/src/switch.component.ts @@ -29,7 +29,7 @@ import { ɵSdFormControlParent, } from '@sdcorejs/angular/forms/models'; import { sdIsEmpty, sdSerializeDataValue } from '@sdcorejs/angular/utilities/data-state'; -import { Color } from '@sdcorejs/utils/models'; +import { Color, Size } from '@sdcorejs/utils/models'; import { Subscription } from 'rxjs'; @Component({ @@ -38,6 +38,7 @@ import { Subscription } from 'rxjs'; styleUrl: './switch.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { + '[attr.data-size]': 'size()', // why: dùng host class .sd-c- (thay vì data-attr) để reactivity host-binding với // signal chắc chắn áp dụng + có default fallback `sd-c-primary` khi color() là 'primary'. // Test cũ assert data-sd-color đã được thay bằng class assert tương ứng. @@ -71,6 +72,7 @@ export class SdSwitch implements OnInit, OnDestroy { readonly label = input(undefined, { transform: (v): string | undefined => v ?? undefined, }); + readonly size = input('md'); // why: legacy callers pass `null` to mean "fallback to primary" — keep that contract readonly color = input('primary', { transform: (v): Color => v || 'primary', diff --git a/versions/v22/SYNC-STATUS.md b/versions/v22/SYNC-STATUS.md index 78e736a6d..8a03094b0 100644 --- a/versions/v22/SYNC-STATUS.md +++ b/versions/v22/SYNC-STATUS.md @@ -4,7 +4,7 @@ |-----|-------| | Angular Major | 22 | | Legacy Source Commit | d12478a1 | -| Updated At | 2026-09-11 15:55:24 | +| Updated At | 2026-09-11 16:48:14 | | Origin | repo-owned versions/v19 (final legacy sync vn-angular@d12478a1) | | Workspace Flow | versions/v19 → v22 | | Development Mode | repo-owned independent pack | diff --git a/versions/v22/projects/sdcorejs-angular/components/table/sd-table.md b/versions/v22/projects/sdcorejs-angular/components/table/sd-table.md index bd8134c44..986508fa8 100644 --- a/versions/v22/projects/sdcorejs-angular/components/table/sd-table.md +++ b/versions/v22/projects/sdcorejs-angular/components/table/sd-table.md @@ -425,6 +425,10 @@ command: { } ``` +### Switches in cells + +Switches (`sd-switch`) rendered within the table use the compact `sm` appearance (36×20px track), including projected consumer cell templates and mobile cards. This table styling takes precedence over the switch size; switches outside the table still default to `md`. + ## Inputs (the host element) | Name | Type | Default | Notes | diff --git a/versions/v22/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts b/versions/v22/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts new file mode 100644 index 000000000..95887bfe2 --- /dev/null +++ b/versions/v22/projects/sdcorejs-angular/components/table/src/table-switch-size.spec.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy as SdAngular22ChangeDetectionStrategy } from '@angular/core'; +import { Component } from '@angular/core'; +import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SdSwitch } from '@sdcorejs/angular/forms/switch'; +import { SdTable } from './table.component'; +import { SdTableCellDefDirective } from './directives/sd-table-cell-def.directive'; +import { SdTableOption } from './models/table-option.model'; + +@Component({ + changeDetection: SdAngular22ChangeDetectionStrategy.Eager, + imports: [SdTable, SdTableCellDefDirective, SdSwitch], + template: ` + + + + + + + + `, +}) +class SwitchTableHost { + row = { id: 1, active: false }; + option: SdTableOption = { + type: 'local', + items: () => [this.row], + columns: [{ field: 'active', title: 'Active', type: 'boolean' }], + }; +} + +describe('SdTable switch sizing', () => { + it('compacts a consumer cell switch to sm without affecting an outside switch or its model binding', fakeAsync(() => { + TestBed.configureTestingModule({ imports: [SwitchTableHost, NoopAnimationsModule] }); + const fixture = TestBed.createComponent(SwitchTableHost); + fixture.detectChanges(); + tick(800); + flush(); + fixture.detectChanges(); + const inside = fixture.nativeElement.querySelector('sd-table sd-switch') as HTMLElement; + const outside = fixture.nativeElement.querySelector('sd-switch') as HTMLElement; + expect(inside).not.toBeNull(); + expect(inside.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(36); + expect(inside.querySelector('.mdc-switch__track')!.getBoundingClientRect().height).toBe(20); + const explicitLarge = fixture.nativeElement.querySelector('sd-table sd-switch[size="lg"]') as HTMLElement; + expect(explicitLarge.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(36); + expect(outside.querySelector('.mdc-switch__track')!.getBoundingClientRect().width).toBe(52); + inside.querySelector('button[role="switch"]')!.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.row.active).toBeTrue(); + fixture.destroy(); + flush(); + })); +}); diff --git a/versions/v22/projects/sdcorejs-angular/forms/switch/sd-switch.md b/versions/v22/projects/sdcorejs-angular/forms/switch/sd-switch.md index a8f287721..01e6b2c4e 100644 --- a/versions/v22/projects/sdcorejs-angular/forms/switch/sd-switch.md +++ b/versions/v22/projects/sdcorejs-angular/forms/switch/sd-switch.md @@ -30,7 +30,7 @@ iOS-style toggle switch — boolean ON/OFF in a single tap. Use for feature flag | ----------------- | ------------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `autoId` | `string \| null \| undefined` | `undefined` | Generates `data-autoId="forms-switch-"` for E2E selectors. | | `name` | `string` | random uuid | FormGroup control name when bound via `[form]`. | -| `size` | `Size` (`'sm' \| 'md' \| 'lg'`) | `'md'` | Reserved; current template does not branch on this, so `size="sm"` does not compact switch UI yet. | +| `size` | `Size` (`'sm' \| 'md' \| 'lg'`) | `'md'` | Track, handle, icon and focus-layer sizing. `sm`: 36×20px; `md`: 52×32px; `lg`: 60×36px. Inside `sd-table`, the compact `sm` appearance takes precedence. | | `form` | `NgForm \| FormGroup \| undefined \| null` | `undefined` | Parent form. `NgForm` is auto-unwrapped to its inner `FormGroup`. | | `label` | `string \| undefined` | `undefined` | Label rendered to the right of the toggle (via ``). | | `color` | `Color` | `'primary'` | Material color for the ON state knob/track. | @@ -94,6 +94,16 @@ None — label comes from the `[label]` input. - Inline error message (``) appears below the row in red once the control is touched/dirty and `formControl.errors?.required` is set (i.e. `required` was set and the value is still `null`/`undefined`); suppressed when `[hideInlineError]="true"` - No outlined `mat-form-field` chrome — visually denser and lighter than `` / `` +## Sizes + +`size` can be a literal or a reactive binding. Changing it preserves the model, validators, disabled state and form registration. Read-only `viewed` text keeps its existing typography. On touch devices the interactive control retains a minimum 44px height. + +```html + + + +``` + ## Standalone imports and table-cell usage Every standalone host that uses `` must import `SdSwitch`. @@ -108,8 +118,8 @@ import { SdSwitch } from '@sdcorejs/angular/forms'; imports: [SdTable, SdTableCellDefDirective, SdSwitch], template: ` - - + + `, @@ -119,20 +129,20 @@ export class ActiveTableComponent { } ``` -`size="sm"` is currently reserved for future switch sizing and does not compact switch UI yet. In table cells, use `hideInlineError` and keep labels short or omit the label. +Switches rendered inside `sd-table` automatically use the compact `sm` appearance, including consumer cell templates and mobile cards. This table appearance takes precedence over the `size` input; outside tables the default remains `md`. Use `hideInlineError` when the cell must not grow to show inline validation, and provide a concise label. ```html - - + + ``` ## Dense dashboard/filter usage -When this control is rendered in dashboard cards, filter bars, external filter panels, table toolbars, query bars, or other compact non-form surfaces, prefer `hideInlineError` so Material does not reserve the inline error/subscript row under the field. Pair it with `size="sm"` when the component supports `size`. Validation remains visible through the compact error icon/tooltip without increasing the control height, and the message is also exposed to assistive tech through a screen-reader-only element (`span.sd-visually-hidden`) referenced by `aria-describedby`. +Use `size="sm"` in compact filter bars, dashboard cards and toolbars. Add `hideInlineError` only when inline validation feedback is shown elsewhere: it suppresses the message, while validation still runs. The switch does not render a replacement error icon or tooltip. ```html - + ``` ## Examples diff --git a/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.html b/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.html index cb2d63ff7..5ad7942af 100644 --- a/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.html +++ b/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.html @@ -1,6 +1,7 @@ +@let _label = label(); @if (isViewed()) { - @if (label()) { -
{{ label() }}
+ @if (_label) { +
{{ _label }}
}
{{ (model() ? 'core.form.switch.on' : 'core.form.switch.off') | sdTranslate }}
} @else { @@ -11,7 +12,7 @@ [attr.data-empty]="dataEmpty()" [attr.data-value]="dataValue()" [attr.data-required]="dataRequired()"> - + @let _errorMessage = visibleErrorMessage(); @if (_errorMessage) { diff --git a/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.scss b/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.scss index 8363e5d47..21064be56 100644 --- a/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.scss +++ b/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.scss @@ -1,4 +1,76 @@ +@use 'sass:math'; + +@mixin switch-size($width, $height, $handle, $off-handle, $pressed, $icon, $state-layer) { + --sd-switch-width: #{$width}; + --sd-switch-height: #{$height}; + --sd-switch-handle-size: #{$handle}; + --sd-switch-off-handle-size: #{$off-handle}; + --sd-switch-pressed-size: #{$pressed}; + --sd-switch-icon-size: #{$icon}; + --sd-switch-state-layer-size: #{$state-layer}; + --sd-switch-selected-margin: 0 #{$width - math.div($height + $handle, 2)}; + --sd-switch-unselected-margin: 0 #{math.div($height - $off-handle, 2)}; + --sd-switch-unselected-icon-margin: 0 #{math.div($height - $handle, 2)}; + --sd-switch-selected-pressed-margin: 0 #{$width - math.div($height + $pressed, 2)}; + --sd-switch-unselected-pressed-margin: 0 #{math.div($height - $pressed, 2)}; +} + +:host { + @include switch-size(52px, 32px, 24px, 16px, 28px, 16px, 40px); +} + +:host([data-size='lg']) { + @include switch-size(60px, 36px, 28px, 20px, 32px, 20px, 44px); +} + +// why: cell templates thuộc consumer nên dùng ngữ cảnh DOM để switch trong bảng luôn gọn, +// kể cả template được chiếu qua nhiều lớp component hoặc chuyển sang mobile card. +:host([data-size='sm']), +:host-context(sd-table)[data-size] { + @include switch-size(36px, 20px, 16px, 12px, 18px, 12px, 28px); +} + +:host ::ng-deep .mat-mdc-slide-toggle, +:host ::ng-deep .mdc-switch { + // why: v19 dùng mdc-switch/mat-switch, các major mới dùng mat-slide-toggle. + @each $prefix in mdc-switch, mat-slide-toggle { + --#{$prefix}-track-width: var(--sd-switch-width) !important; + --#{$prefix}-track-height: var(--sd-switch-height) !important; + --#{$prefix}-track-shape: 999px !important; + --#{$prefix}-state-layer-size: var(--sd-switch-state-layer-size) !important; + --#{$prefix}-selected-icon-size: var(--sd-switch-icon-size) !important; + --#{$prefix}-unselected-icon-size: var(--sd-switch-icon-size) !important; + } + @each $prefix in mat-switch, mat-slide-toggle { + --#{$prefix}-selected-handle-size: var(--sd-switch-handle-size) !important; + --#{$prefix}-unselected-handle-size: var(--sd-switch-off-handle-size) !important; + --#{$prefix}-with-icon-handle-size: var(--sd-switch-handle-size) !important; + --#{$prefix}-pressed-handle-size: var(--sd-switch-pressed-size) !important; + --#{$prefix}-selected-handle-horizontal-margin: var(--sd-switch-selected-margin) !important; + --#{$prefix}-selected-with-icon-handle-horizontal-margin: var(--sd-switch-selected-margin) !important; + --#{$prefix}-unselected-handle-horizontal-margin: var(--sd-switch-unselected-margin) !important; + --#{$prefix}-unselected-with-icon-handle-horizontal-margin: var(--sd-switch-unselected-icon-margin) !important; + --#{$prefix}-selected-pressed-handle-horizontal-margin: var(--sd-switch-selected-pressed-margin) !important; + --#{$prefix}-unselected-pressed-handle-horizontal-margin: var(--sd-switch-unselected-pressed-margin) !important; + } +} + :host ::ng-deep { + .mdc-switch { + min-height: max(28px, var(--sd-switch-height)); + } + + .mat-mdc-slide-toggle-touch-target { + height: max(28px, var(--sd-switch-height)); + } + + @media (pointer: coarse) { + .mdc-switch, + .mat-mdc-slide-toggle-touch-target { + min-height: 44px; + } + } + // NOTE: Bootstrap reboot adds margin-bottom to label; keep this override while Bootstrap is used. label { margin-bottom: 0 !important; @@ -13,11 +85,21 @@ --sd-switch-track-off-hover: var(--sd-border, #c4c6d0); --sd-switch-handle-off: var(--sd-text-secondary, #44474f); } -:host(.sd-c-secondary) { --sd-c: var(--sd-secondary); } -:host(.sd-c-info) { --sd-c: var(--sd-info); } -:host(.sd-c-success) { --sd-c: var(--sd-success); } -:host(.sd-c-warning) { --sd-c: var(--sd-warning); } -:host(.sd-c-error) { --sd-c: var(--sd-error); } +:host(.sd-c-secondary) { + --sd-c: var(--sd-secondary); +} +:host(.sd-c-info) { + --sd-c: var(--sd-info); +} +:host(.sd-c-success) { + --sd-c: var(--sd-success); +} +:host(.sd-c-warning) { + --sd-c: var(--sd-warning); +} +:host(.sd-c-error) { + --sd-c: var(--sd-error); +} // why: Angular Material exposes two switch token tiers across supported versions: // - `--mdc-switch-*` on `.mdc-switch` diff --git a/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts b/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts index 8906c77be..0014d2b2e 100644 --- a/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts +++ b/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.spec.ts @@ -3,6 +3,7 @@ import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup, FormsModule, NgForm, ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/slide-toggle'; import { SdSwitch } from './switch.component'; @Component({ @@ -384,3 +385,97 @@ describe('SdSwitch (viewed inline mode)', () => { expect(fixture.nativeElement.querySelector('mat-slide-toggle')).toBeNull(); }); }); + +describe('SdSwitch sizes', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SdSwitch, NoopAnimationsModule], + providers: [{ provide: MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS, useValue: {} }], + }).compileComponents(); + fixture = TestBed.createComponent(SdSwitch); + fixture.componentRef.setInput('label', 'Notifications'); + }); + + it('keeps the default medium track at 52 × 32px', () => { + fixture.detectChanges(); + const track = fixture.nativeElement.querySelector('.mdc-switch__track') as HTMLElement; + expect(track.getBoundingClientRect().width).toBe(52); + expect(track.getBoundingClientRect().height).toBe(32); + expect(fixture.nativeElement.getAttribute('data-size')).toBe('md'); + }); + + for (const [size, width, height, handleSize] of [ + ['sm', 36, 20, 16], + ['md', 52, 32, 24], + ['lg', 60, 36, 28], + ] as const) { + it(`renders ${size} with centered handles in both states and preserves toggling/disabled behavior`, () => { + fixture.componentRef.setInput('size', size); + fixture.detectChanges(); + const change = spyOn(fixture.componentInstance.sdChange, 'emit').and.callThrough(); + const button = fixture.nativeElement.querySelector('button[role="switch"]') as HTMLButtonElement; + const track = fixture.nativeElement.querySelector('.mdc-switch__track') as HTMLElement; + const handle = fixture.nativeElement.querySelector('.mdc-switch__handle') as HTMLElement; + for (const checked of [false, true]) { + fixture.componentRef.setInput('model', checked); + fixture.detectChanges(); + const t = track.getBoundingClientRect(); + const h = handle.getBoundingClientRect(); + expect(t.width).toBe(width); + expect(t.height).toBe(height); + expect(h.width).toBe(handleSize); + expect(h.height).toBe(handleSize); + expect(h.top + h.height / 2).toBeCloseTo(t.top + t.height / 2, 0); + expect(h.left).toBeGreaterThanOrEqual(t.left); + expect(h.right).toBeLessThanOrEqual(t.right); + expect(button.getAttribute('aria-checked')).toBe(String(checked)); + } + change.calls.reset(); + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.model()).toBeFalse(); + expect(change).toHaveBeenCalledOnceWith(false); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + button.click(); + expect(change).toHaveBeenCalledTimes(1); + expect(track.getBoundingClientRect().width).toBe(width); + }); + } + + it('updates size dynamically without emitting a value change', () => { + fixture.detectChanges(); + const change = spyOn(fixture.componentInstance.sdChange, 'emit'); + for (const [size, width] of [ + ['sm', 36], + ['lg', 60], + ['md', 52], + ] as const) { + fixture.componentRef.setInput('size', size); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.mdc-switch__track').getBoundingClientRect().width).toBe(width); + } + expect(change).not.toHaveBeenCalled(); + }); + + it('keeps the small handle inside its track with hidden icons and RTL direction', async () => { + TestBed.inject(MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS).hideIcon = true; + fixture.componentRef.setInput('size', 'sm'); + fixture.detectChanges(); + fixture.nativeElement.setAttribute('dir', 'rtl'); + for (const checked of [false, true]) { + fixture.componentRef.setInput('model', checked); + fixture.detectChanges(); + const track = fixture.nativeElement.querySelector('.mdc-switch__track').getBoundingClientRect(); + const handleElement = fixture.nativeElement.querySelector('.mdc-switch__handle') as HTMLElement; + // why: Material vẫn transition kích thước núm gạt khi tắt icon dù dùng NoopAnimationsModule. + await Promise.all(handleElement.getAnimations().map(animation => animation.finished)); + const handle = handleElement.getBoundingClientRect(); + expect(handle.width).toBe(checked ? 16 : 12); + expect(handle.left).toBeGreaterThanOrEqual(track.left); + expect(handle.right).toBeLessThanOrEqual(track.right); + } + }); +}); diff --git a/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.ts b/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.ts index 3e6db59f4..d4c46a188 100644 --- a/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.ts +++ b/versions/v22/projects/sdcorejs-angular/forms/switch/src/switch.component.ts @@ -29,7 +29,7 @@ import { ɵSdFormControlParent, } from '@sdcorejs/angular/forms/models'; import { sdIsEmpty, sdSerializeDataValue } from '@sdcorejs/angular/utilities/data-state'; -import { Color } from '@sdcorejs/utils/models'; +import { Color, Size } from '@sdcorejs/utils/models'; import { Subscription } from 'rxjs'; @Component({ @@ -38,6 +38,7 @@ import { Subscription } from 'rxjs'; styleUrl: './switch.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { + '[attr.data-size]': 'size()', // why: dùng host class .sd-c- (thay vì data-attr) để reactivity host-binding với // signal chắc chắn áp dụng + có default fallback `sd-c-primary` khi color() là 'primary'. // Test cũ assert data-sd-color đã được thay bằng class assert tương ứng. @@ -71,6 +72,7 @@ export class SdSwitch implements OnInit, OnDestroy { readonly label = input(undefined, { transform: (v): string | undefined => v ?? undefined, }); + readonly size = input('md'); // why: legacy callers pass `null` to mean "fallback to primary" — keep that contract readonly color = input('primary', { transform: (v): Color => v || 'primary',