From bbcfb749c08ed90877ecc9bc5eb4c4506fd0d35f Mon Sep 17 00:00:00 2001 From: Simeon Simeonoff Date: Thu, 3 Sep 2026 14:39:43 +0300 Subject: [PATCH 1/2] fix(checkbox,ripple): restore theme inheritance for scoped tick mark Fixed the tick-mark icon rendering with the Indigo shape (rounded rect + custom path) inside CSS-scoped subtrees that use a different design system than the application's global theme, e.g. a `material`-themed widget nested inside an `indigo`-themed app. Both tick-mark variants are now always rendered and toggled purely via CSS (`@container style(--ig-theme: indigo)`), removing the dependency on JS-side theme detection that could go stale in nested/multi-theme scenarios (#15021) --- .../calendar/days-view/days-view.component.ts | 67 +------------------ .../src/checkbox/checkbox.component.html | 17 ++--- .../src/checkbox/checkbox.component.spec.ts | 58 +++++++++++++++- .../src/checkbox/checkbox.component.ts | 52 -------------- .../checkbox/src/checkbox/themes/_base.scss | 4 ++ .../src/checkbox/themes/shared/_indigo.scss | 13 ++-- .../components/ripple/_ripple-theme.scss | 8 +++ .../igniteui-angular/core/src/core/utils.ts | 11 ++- .../checkbox/checkbox-base.directive.ts | 42 +----------- .../input-group/input-group.component.spec.ts | 37 +++++++++- 10 files changed, 136 insertions(+), 173 deletions(-) diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts index c849582c50f..31463d42cb0 100644 --- a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts @@ -12,8 +12,7 @@ import { ChangeDetectorRef, ChangeDetectionStrategy, ViewEncapsulation, - inject, - AfterContentChecked + inject } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { TitleCasePipe } from '@angular/common'; @@ -28,11 +27,7 @@ import { getNextActiveDate, getPreviousActiveDate, intoChunks, - isDateInRanges, - getComponentTheme, - IgxTheme, - THEME_TOKEN, - ThemeToken + isDateInRanges } from 'igniteui-angular/core'; import { IgxCalendarBaseDirective } from '../calendar-base'; import { IViewChangingEventArgs } from './days-view.interface'; @@ -58,10 +53,9 @@ let NEXT_ID = 0; changeDetection: ChangeDetectionStrategy.OnPush, imports: [IgxDayItemComponent, TitleCasePipe, DayDigitPipe] }) -export class IgxDaysViewComponent extends IgxCalendarBaseDirective implements AfterContentChecked { +export class IgxDaysViewComponent extends IgxCalendarBaseDirective { protected el = inject(ElementRef); public override cdr = inject(ChangeDetectorRef); - private themeToken: ThemeToken = inject(THEME_TOKEN); #standalone = true; /** @@ -210,65 +204,10 @@ export class IgxDaysViewComponent extends IgxCalendarBaseDirective implements Af private _hideLeadingDays!: boolean; private _hideTrailingDays!: boolean; private _showActiveDay!: boolean; - private _theme: IgxTheme; @HostBinding('class.igx-days-view') public defaultClass = true; - // Theme-specific classes - @HostBinding('class.igx-days-view--material') - protected get isMaterial(): boolean { - return this._theme === 'material'; - } - - @HostBinding('class.igx-days-view--fluent') - protected get isFluent(): boolean { - return this._theme === 'fluent'; - } - - @HostBinding('class.igx-days-view--bootstrap') - protected get isBootstrap(): boolean { - return this._theme === 'bootstrap'; - } - - @HostBinding('class.igx-days-view--indigo') - protected get isIndigo(): boolean { - return this._theme === 'indigo'; - } - - /** - * @hidden - */ - constructor() { - super(); - this._theme = this.themeToken.theme; - - const themeChange = this.themeToken.onChange((theme) => { - if (this._theme !== theme) { - this._theme = theme; - this.cdr.detectChanges(); - } - }); - - this._destroyRef.onDestroy(() => themeChange.unsubscribe()); - } - - private setComponentTheme() { - // allow DOM theme override (same pattern as input-group) - if (!this.themeToken.preferToken) { - const theme = getComponentTheme(this.el.nativeElement); - - if (theme && theme !== this._theme) { - this._theme = theme; - this.cdr.markForCheck(); - } - } - } - - public ngAfterContentChecked() { - this.setComponentTheme(); - } - /** * @hidden */ diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html index 9bab0879680..a2fce07ba00 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html @@ -25,16 +25,13 @@ class="igx-checkbox__composite-wrapper" > - @if (theme === 'indigo') { - - - - - } @else { - - - - } + + + + + + +
diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts index 80db7e6bce1..87c8e7d894b 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, ViewChild, inject, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, ElementRef, inject, ChangeDetectionStrategy } from '@angular/core'; import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { UntypedFormBuilder, FormsModule, ReactiveFormsModule, Validators, NgForm } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -21,11 +21,45 @@ describe('IgxCheckbox', () => { CheckboxDisabledTransitionsComponent, CheckboxFormComponent, CheckboxFormGroupComponent, + CheckboxNestedThemeScopeComponent, IgxCheckboxComponent ] }).compileComponents(); })); + /** + * Regression tests for https://github.com/IgniteUI/igniteui-angular/issues/15021 + */ + describe('Theming (issue #15021)', () => { + const getMarks = (host: HTMLElement) => ({ + indigoMark: host.querySelector('.igx-checkbox__composite-mark--indigo') as HTMLElement, + defaultMark: host.querySelector('.igx-checkbox__composite-mark:not(.igx-checkbox__composite-mark--indigo)') as HTMLElement + }); + const isVisible = (el: HTMLElement) => getComputedStyle(el).display !== 'none'; + + it('renders both tick-mark SVGs, with only one visible per the active --ig-theme', () => { + const fixture = TestBed.createComponent(CheckboxNestedThemeScopeComponent); + fixture.detectChanges(); + + const root = getMarks(fixture.componentInstance.rootCbHost.nativeElement); + expect(isVisible(root.defaultMark)).toBe(true); + expect(isVisible(root.indigoMark)).toBe(false); + + const indigoScoped = getMarks(fixture.componentInstance.indigoCbHost.nativeElement); + expect(isVisible(indigoScoped.indigoMark)).toBe(true); + expect(isVisible(indigoScoped.defaultMark)).toBe(false); + }); + + it('shows the material tick mark for a checkbox in a material-scoped wrapper nested inside an indigo-scoped one', () => { + const fixture = TestBed.createComponent(CheckboxNestedThemeScopeComponent); + fixture.detectChanges(); + + const nested = getMarks(fixture.componentInstance.materialCbHost.nativeElement); + expect(isVisible(nested.defaultMark)).toBe(true); + expect(isVisible(nested.indigoMark)).toBe(false); + }); + }); + it('Initializes a checkbox', () => { const fixture = TestBed.createComponent(InitCheckboxComponent); fixture.detectChanges(); @@ -412,6 +446,28 @@ describe('IgxCheckbox', () => { }); }); +@Component({ + template: `Root +
+ Indigo scope +
+ Material scope nested in indigo +
+
`, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxCheckboxComponent] +}) +class CheckboxNestedThemeScopeComponent { + @ViewChild('rootCb', { static: true }) public rootCb: IgxCheckboxComponent; + @ViewChild('indigoCb', { static: true }) public indigoCb: IgxCheckboxComponent; + @ViewChild('materialCb', { static: true }) public materialCb: IgxCheckboxComponent; + @ViewChild('rootCb', { static: true, read: ElementRef }) public rootCbHost: ElementRef; + @ViewChild('indigoCb', { static: true, read: ElementRef }) public indigoCbHost: ElementRef; + @ViewChild('materialCb', { static: true, read: ElementRef }) public materialCbHost: ElementRef; + @ViewChild('indigoWrapper', { static: true }) public indigoWrapper: ElementRef; + @ViewChild('materialWrapper', { static: true }) public materialWrapper: ElementRef; +} + @Component({ template: `Init`, changeDetection: ChangeDetectionStrategy.Eager, diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts index 3b37db76114..074fd84d7b9 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts @@ -63,58 +63,6 @@ export class IgxCheckboxComponent @HostBinding('class.igx-checkbox') public override cssClass = 'igx-checkbox'; - /** - * Returns if the component is of type `material`. - * - * @example - * ```typescript - * let checkbox = this.checkbox.material; - * ``` - */ - @HostBinding('class.igx-checkbox--material') - protected get material() { - return this.theme === 'material'; - } - - /** - * Returns if the component is of type `indigo`. - * - * @example - * ```typescript - * let checkbox = this.checkbox.indigo; - * ``` - */ - @HostBinding('class.igx-checkbox--indigo') - protected get indigo() { - return this.theme === 'indigo'; - } - - /** - * Returns if the component is of type `bootstrap`. - * - * @example - * ```typescript - * let checkbox = this.checkbox.bootstrap; - * ``` - */ - @HostBinding('class.igx-checkbox--bootstrap') - protected get bootstrap() { - return this.theme === 'bootstrap'; - } - - /** - * Returns if the component is of type `fluent`. - * - * @example - * ```typescript - * let checkbox = this.checkbox.fluent; - * ``` - */ - @HostBinding('class.igx-checkbox--fluent') - protected get fluent() { - return this.theme === 'fluent'; - } - /** * Sets/gets whether the checkbox component is on focus. * Default value is `false`. diff --git a/projects/igniteui-angular/checkbox/src/checkbox/themes/_base.scss b/projects/igniteui-angular/checkbox/src/checkbox/themes/_base.scss index f922ab3eb16..fd17bac5884 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/themes/_base.scss +++ b/projects/igniteui-angular/checkbox/src/checkbox/themes/_base.scss @@ -174,6 +174,10 @@ $_theme: digest-schema($material-checkbox); z-index: 1; } + @include e(composite-mark, $m: indigo) { + display: none; + } + @include e(ripple) { --_ripple-size: #{rem(40px)}; diff --git a/projects/igniteui-angular/checkbox/src/checkbox/themes/shared/_indigo.scss b/projects/igniteui-angular/checkbox/src/checkbox/themes/shared/_indigo.scss index c8cb47722ea..462b6e2f750 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/themes/shared/_indigo.scss +++ b/projects/igniteui-angular/checkbox/src/checkbox/themes/shared/_indigo.scss @@ -17,7 +17,12 @@ $_theme: digest-schema($indigo-checkbox); } } - @include e(composite-mark) { + @include e(composite-mark, $not: indigo) { + display: none; + } + + @include e(composite-mark, $m: indigo) { + display: initial; stroke: unset; stroke-linecap: unset; stroke-width: unset; @@ -86,7 +91,7 @@ $_theme: digest-schema($indigo-checkbox); } } - @include e(composite-mark) { + @include e(composite-mark, $m: indigo) { fill: none; stroke-dashoffset: unset; rotate: none; @@ -152,13 +157,13 @@ $_theme: digest-schema($indigo-checkbox); } @include mx(disabled, checked) { - @include e(composite-mark) { + @include e(composite-mark, $m: indigo) { fill: var-get($_theme, 'disabled-tick-color'); } } @include mx(disabled, indeterminate) { - @include e(composite-mark) { + @include e(composite-mark, $m: indigo) { stroke: unset; fill: none; diff --git a/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-theme.scss index 36992d9b067..d48e764ce20 100644 --- a/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-theme.scss +++ b/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-theme.scss @@ -8,6 +8,11 @@ @mixin ripple($theme) { @include tokens($theme, $mode: 'scoped', $scope: '[igxRipple]'); + [igxRipple] { + --ig-theme: inherit; + --ig-theme-variant: inherit; + } + @include layer(base) { %igx-ripple-display { display: block; @@ -24,6 +29,9 @@ } %igx-ripple-wrapper { + --ig-theme: inherit; + --ig-theme-variant: inherit; + overflow: hidden; } } diff --git a/projects/igniteui-angular/core/src/core/utils.ts b/projects/igniteui-angular/core/src/core/utils.ts index 588bae542f6..e1883918ce4 100644 --- a/projects/igniteui-angular/core/src/core/utils.ts +++ b/projects/igniteui-angular/core/src/core/utils.ts @@ -681,10 +681,19 @@ export function normalizeURI(path: string) { return path?.split('/').map(encodeURI).join('/'); } +/** + * Reads the theme that is actually in effect at a given DOM position, based on the + * live `--ig-theme` custom property (see `themes/_scoping.scss`'s `themed()` mixin, + * the single source of truth for coupling emitted CSS to the runtime theme signal). + * `--ig-theme` inherits, so this reflects the nearest CSS-scoped `theme(...)` call + * (if any), not necessarily the app's root/global theme. + * + * @param el - The element to read the effective theme for. + */ export function getComponentTheme(el: Element) { return globalThis.window ?.getComputedStyle(el) - .getPropertyValue('--theme') + .getPropertyValue('--ig-theme') .trim() as IgxTheme; } diff --git a/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts b/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts index 7ac42387b17..07c1aa393e9 100644 --- a/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts @@ -1,13 +1,8 @@ -import { Directive, EventEmitter, HostListener, HostBinding, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, DestroyRef, AfterViewInit } from '@angular/core'; +import { Directive, EventEmitter, HostListener, HostBinding, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, AfterViewInit } from '@angular/core'; import { NgControl, Validators } from '@angular/forms'; -import { IBaseEventArgs, getComponentTheme } from 'igniteui-angular/core'; +import { IBaseEventArgs } from 'igniteui-angular/core'; import { noop, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { - IgxTheme, - THEME_TOKEN, - ThemeToken, -} from 'igniteui-angular/core'; export const LabelPosition = { BEFORE: 'before', @@ -25,7 +20,6 @@ let nextId = 0; @Directive() export class CheckboxBaseDirective implements AfterViewInit { protected cdr = inject(ChangeDetectorRef); - protected themeToken = inject(THEME_TOKEN); public ngControl = inject(NgControl, { optional: true, self: true }); /** @@ -238,17 +232,6 @@ export class CheckboxBaseDirective implements AfterViewInit { if (this.ngControl !== null) { this.ngControl.valueAccessor = this; } - - this.theme = this.themeToken.theme; - - const themeChange = this.themeToken.onChange((theme) => { - if (this.theme !== theme) { - this.theme = theme; - this.cdr.detectChanges(); - } - }); - - this.destroyRef.onDestroy(() => themeChange.unsubscribe()); } /** @@ -294,8 +277,6 @@ export class CheckboxBaseDirective implements AfterViewInit { this.cdr.detectChanges(); } } - - this.setComponentTheme(); } /** @@ -320,30 +301,11 @@ export class CheckboxBaseDirective implements AfterViewInit { */ protected _checked = false; - /** - * @hidden - * @internal - */ - protected theme: IgxTheme; - /** * @hidden * @internal */ public _required = false; - private elRef = inject(ElementRef); - protected destroyRef = inject(DestroyRef); - - private setComponentTheme() { - if (!this.themeToken.preferToken) { - const theme = getComponentTheme(this.elRef.nativeElement); - - if (theme && theme !== this.theme) { - this.theme = theme; - this.cdr.markForCheck(); - } - } - } /** @hidden @internal */ @HostListener('keyup', ['$event']) diff --git a/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts b/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts index b10d4829a94..0822c2f92a8 100644 --- a/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts +++ b/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts @@ -22,7 +22,8 @@ describe('IgxInputGroup', () => { InputGroupFileComponent, InputGroupDisabledComponent, InputGroupDisabledByDefaultComponent, - InputGroupDisabledWithoutValueComponent + InputGroupDisabledWithoutValueComponent, + InputGroupNestedThemeScopeComponent ] }).compileComponents(); })); @@ -239,8 +240,42 @@ describe('IgxInputGroup', () => { inputGroupDebugElement.triggerEventHandler('click', pointerEvent); expect(document.activeElement).toEqual(input.nativeElement); }); + + /** + * Regression coverage for the `getComponentTheme` fix (was reading the dead `--theme` + * custom property; now reads `--ig-theme`, matching what `themes/_scoping.scss` emits). + * Verifies an input-group correctly reflects the CSS-scoped `--ig-theme` in effect at + * its own DOM position rather than the app-wide/global theme. + */ + it('reflects the CSS-scoped --ig-theme at its own DOM position, not the global theme', fakeAsync(() => { + const fixture = TestBed.createComponent(InputGroupNestedThemeScopeComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(fixture.componentInstance.rootGroup.isTypeIndigo).toBe(false); + expect(fixture.componentInstance.indigoGroup.isTypeIndigo).toBe(true); + expect(fixture.componentInstance.materialGroup.isTypeIndigo).toBe(false); + })); }); +@Component({ + template: ` +
+ +
+ +
+
`, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxInputGroupComponent, IgxInputDirective] +}) +class InputGroupNestedThemeScopeComponent { + @ViewChild('rootGroup', { static: true }) public rootGroup: IgxInputGroupComponent; + @ViewChild('indigoGroup', { static: true }) public indigoGroup: IgxInputGroupComponent; + @ViewChild('materialGroup', { static: true }) public materialGroup: IgxInputGroupComponent; +} + @Component({ template: ` PREFIX From a657d401b0d0cff808f744d8d7f0277b75090c7b Mon Sep 17 00:00:00 2001 From: Simeon Simeonoff Date: Thu, 3 Sep 2026 15:34:09 +0300 Subject: [PATCH 2/2] chore(changelog): update to reflect fix --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c785ceede4..d4962e7a8c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ All notable changes for each version of this project will be documented in this - **Theming** - Scrollbar arrow buttons cannot be styled or enabled through the standard properties, and `scrollbar-width: thin` removes them where the platform draws them. - **Firefox** - The `scrollbar-color` and `scrollbar-width` properties are not supported on Firefox versions prior to 64, so the scrollbars in those versions will render with the platform default colors and size. +### Bug Fixes + +- `IgxCheckboxComponent` + - Fixed the tick-mark icon rendering with the Indigo shape (rounded rect + custom path) inside CSS-scoped subtrees that use a different design system than the application's global theme, e.g. a `material`-themed widget nested inside an `indigo`-themed app. Both tick-mark variants are now always rendered and toggled purely via CSS (`@container style(--ig-theme: indigo)`), removing the dependency on JS-side theme detection that could go stale in nested/multi-theme scenarios (#15021). +- **Ripple** + - Fixed `[igxRipple]` unconditionally stamping `--ig-theme`/`--ig-theme-variant` (from its own compile-time schema) onto its host element, which broke runtime theme inheritance for any content nested inside a ripple host (e.g. a checkbox's tick mark) when that content sat in a differently CSS-scoped theme than the app's global one. + ## 22.1.0 ### New Features