Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ All notable changes for each version of this project will be documented in this

- `IgxDialogComponent`
- Fixed `open()` leaving dialogs hidden when called through an `OnPush` child component from a parent or an asynchronous callback, in both zoneless and ZoneJS applications.
- `IgxGridComponent`, `IgxTreeGridComponent`, `IgxHierarchicalGridComponent`, `IgxPivotGridComponent`
- Restored the style recalculation performance of 21.2.x in Chromium-based browsers. The grid, Excel-style filtering and query builder themes no longer use multi-selector `:where()` lists as rule subjects, which Chromium matched against every element on each style recalculation. The styles are otherwise unchanged.
- Filtering, grouping, editing and toggling `isLoading` no longer add and remove the stylesheets of the badge, chip, checkbox, input group, date picker and circular progress bar components, which forced a restyle of the whole page each time. The grid now keeps these stylesheets for the application's lifetime. As a result, the first grid in an application uses up one auto-generated `id` of the badge, chip, checkbox, date picker and circular progress bar (including `igx-circular-gradient-N`), so instances created after it get an `id` one higher than before. Later grids do not shift the ids further.
- `IgxGridExcelStyleFilteringComponent`
- Menu spacing now follows `--ig-size` when `minHeight` or `maxHeight` is set, including when rendered in an external outlet or outside the grid. Previously it could fall back to large spacing (#17589).
- **Forms**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@
}
}

// stylelint-disable max-nesting-depth
@include describe('Scope') {
@include it('should wrap each selector of a list in its own :where()') {
@include assert() {
@include output() {
@include scoping.scope(test, '.a, b, .c .d', ()) using ($schema) {
color: red;
}
}

@include expect() {
@layer ig.test {
:where(.a), :where(b), :where(.c .d) {
color: red;
}
}
}
}
}
}
// stylelint-enable max-nesting-depth

@include describe('Generate color class') {
@include it('should concatenate the name, variant, prefix, and suffix in the correct order.') {
$name: primary;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
@use 'sass:list';
@use 'sass:map';
@use 'sass:selector';
@use 'igniteui-theming/sass/bem' as *;
@use 'igniteui-theming/sass/themes' as *;
@forward 'igniteui-theming/sass/themes';
Expand Down Expand Up @@ -93,19 +95,27 @@
}

/// Wraps the passed content in a named cascade layer and scopes it to the given
/// selector through `:where()`, keeping specificity at zero. The active schema is
/// yielded to the content block so callers can resolve themes against it.
/// selector through `:where()`, keeping specificity at zero. Each selector of a list
/// gets its own `:where()`, as Chromium can't bucket multi-argument `:where()` subjects
/// and would match them against every element. The active schema is yielded to the
/// content block so callers can resolve themes against it.
/// @access private
/// @param {String} $layer - The cascade layer name (the `ig.` prefix is added by `layer`).
/// @param {String} $selector - The selector the emitted styles are scoped to.
/// @param {Map} $schema - The active theme schema, yielded to the content block.
/// @content The declarations to emit; receives `$schema` as its single argument,
/// so include it with `using ($schema)`.
/// @requires {mixin} layer
/// @output The content wrapped in `@layer ig.#{$layer}` inside a `:where(#{$selector})` rule.
/// @output The content wrapped in `@layer ig.#{$layer}` inside a `:where(a), :where(b)` rule.
@mixin scope($layer, $selector, $schema) {
$_scoped: ();

@each $complex in selector.parse($selector) {
$_scoped: list.append($_scoped, ':where(#{$complex})', $separator: comma);
}

@include layer($layer) {
:where(#{$selector}) {
#{$_scoped} {
@content($schema);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Component, ElementRef, inject, OnDestroy, ViewEncapsulation } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { PlatformUtil } from '../../core/utils';
import { IgxStylesRegistrar } from './styles-registrar.service';

let instances: ElementRef<HTMLElement>[] = [];
let destroyed = 0;

@Component({
selector: 'igx-retain-test',
template: '',
styles: ['.igx-retain-test { color: red; }'],
encapsulation: ViewEncapsulation.None
})
class RetainTestComponent implements OnDestroy {
constructor() {
instances.push(inject(ElementRef));
}

public ngOnDestroy(): void {
destroyed++;
}
}

@Component({
selector: 'igx-plain-test',
template: '',
styles: ['.igx-plain-test { color: blue; }'],
encapsulation: ViewEncapsulation.None
})
class PlainTestComponent { }

// Compiled styles are reformatted, so match on the class name.
const hasStyle = (className: string) =>
Array.from(document.head.querySelectorAll('style')).some(style => style.textContent?.includes(className));

describe('IgxStylesRegistrar', () => {
describe('register', () => {
let initialSheets: CSSStyleSheet[];

beforeEach(() => {
initialSheets = [...document.adoptedStyleSheets];
});

afterEach(() => {
document.adoptedStyleSheets = initialSheets;
document.head.querySelectorAll('style[data-registrar-test]').forEach(style => style.remove());
});

it('should adopt a stylesheet once per id', () => {
const registrar = TestBed.inject(IgxStylesRegistrar);
const id = Symbol('once');

registrar.register(id, '.igx-register-once { color: red; }');
registrar.register(id, '.igx-register-once { color: red; }');

const added = document.adoptedStyleSheets.slice(initialSheets.length);
expect(added.length).toBe(1);
expect(added[0].cssRules[0].cssText).toContain('.igx-register-once');
});

it('should adopt a separate stylesheet for each id', () => {
const registrar = TestBed.inject(IgxStylesRegistrar);

registrar.register(Symbol('first'), '.igx-register-first { color: red; }');
registrar.register(Symbol('second'), '.igx-register-second { color: red; }');

expect(document.adoptedStyleSheets.length - initialSheets.length).toBe(2);
});

it('should register an id once per document across injectors', () => {
const id = Symbol('per-document');

TestBed.inject(IgxStylesRegistrar).register(id, '.igx-register-doc { color: red; }');
TestBed.resetTestingModule();
TestBed.inject(IgxStylesRegistrar).register(id, '.igx-register-doc { color: red; }');

expect(document.adoptedStyleSheets.length - initialSheets.length).toBe(1);
});

it('should append a style element outside the browser', () => {
TestBed.configureTestingModule({
providers: [{ provide: PlatformUtil, useValue: { isBrowser: false } }]
});

TestBed.inject(IgxStylesRegistrar).register(Symbol('server'), '.igx-register-server { color: red; }');

const style = Array.from(document.head.querySelectorAll('style'))
.find(s => s.textContent?.includes('.igx-register-server'));
style?.setAttribute('data-registrar-test', '');
expect(style).toBeDefined();
expect(document.adoptedStyleSheets.length).toBe(initialSheets.length);
});
});

describe('retain', () => {
// Angular tears down the previous test's app after afterEach hooks run.
beforeEach(() => {
instances = [];
destroyed = 0;
});

it('should keep a component\'s styles after its last instance is destroyed', () => {
TestBed.inject(IgxStylesRegistrar).retain(RetainTestComponent);

const fixture = TestBed.createComponent(RetainTestComponent);
fixture.destroy();

expect(hasStyle('igx-retain-test')).toBe(true);
});

it('should not keep the styles of components that are not retained', () => {
const fixture = TestBed.createComponent(PlainTestComponent);
expect(hasStyle('igx-plain-test')).toBe(true);

fixture.destroy();

expect(hasStyle('igx-plain-test')).toBe(false);
});

it('should create a single detached instance per component', () => {
const registrar = TestBed.inject(IgxStylesRegistrar);

registrar.retain(RetainTestComponent);
registrar.retain(RetainTestComponent, RetainTestComponent);

expect(instances.length).toBe(1);
expect(instances[0].nativeElement.isConnected).toBe(false);
});

it('should destroy the retained instances when the application is destroyed', () => {
TestBed.inject(IgxStylesRegistrar).retain(RetainTestComponent);
expect(destroyed).toBe(0);

TestBed.resetTestingModule();

expect(destroyed).toBe(1);
expect(hasStyle('igx-retain-test')).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { inject, Injectable } from '@angular/core';
import { ApplicationRef, ComponentRef, createComponent, EnvironmentInjector, inject, Injectable, Type } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { PlatformUtil } from '../../core/utils';

Expand Down Expand Up @@ -27,10 +27,14 @@ import { PlatformUtil } from '../../core/utils';
*/
const _registries = new WeakMap<Document, Map<symbol, CSSStyleSheet | null>>();

/** Keyed by application, as Angular shares component styles per application. */
const _retained = new WeakMap<ApplicationRef, Map<Type<unknown>, ComponentRef<unknown>>>();

@Injectable({ providedIn: 'root' })
export class IgxStylesRegistrar {
private readonly _document = inject(DOCUMENT);
private readonly _platform = inject(PlatformUtil);
private readonly _injector = inject(EnvironmentInjector);

/**
* Registers a stylesheet identified by `id`. Subsequent calls with the same
Expand Down Expand Up @@ -62,4 +66,36 @@ export class IgxStylesRegistrar {
this._document.head?.appendChild(style);
}
}

/**
* Keeps the components' styles in the document for the application's lifetime, through a
* detached instance of each. Otherwise Angular removes them with the last instance and
* re-inserts them with the next one, restyling the page each time.
*
* @param components - The components whose styles to retain.
*
* @example
* ```typescript
* inject(IgxStylesRegistrar).retain(IgxBadgeComponent, IgxChipComponent);
* ```
*/
public retain(...components: Type<unknown>[]): void {
const appRef = this._injector.get(ApplicationRef);
let refs = _retained.get(appRef);

if (!refs) {
refs = new Map();
_retained.set(appRef, refs);
appRef.onDestroy(() => {
_retained.get(appRef)?.forEach(ref => ref.destroy());
_retained.delete(appRef);
});
}

for (const type of components) {
if (!refs.has(type)) {
refs.set(type, createComponent(type, { environmentInjector: this._injector }));
}
}
}
}
13 changes: 12 additions & 1 deletion projects/igniteui-angular/grids/grid/src/grid-base.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ import {
import { IgxGridGroupByAreaComponent } from './grouping/grid-group-by-area.component';
import { IgxPaginatorToken, type IgxPaginatorComponent } from 'igniteui-angular/paginator';
import { IgxSnackbarComponent } from 'igniteui-angular/snackbar';
import { IgxBadgeComponent } from 'igniteui-angular/badge';
import { IgxCheckboxComponent } from 'igniteui-angular/checkbox';
import { IgxChipComponent } from 'igniteui-angular/chips';
import { IgxDatePickerComponent } from 'igniteui-angular/date-picker';
import { IgxInputGroupComponent } from 'igniteui-angular/input-group';
import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar';
import { CellType, CharSeparatedValueData, DropPosition, FilterMode, getUUID, GridCellMergeMode, GridKeydownTargetType, GridPagingMode, GridSelectionMode, GridServiceType, GridSummaryPosition, GridType, GridValidationTrigger, IActiveNode, IActiveNodeChangeEventArgs, ICellPosition, IClipboardOptions, IColumnMovingEndEventArgs, IColumnMovingEventArgs, IColumnMovingStartEventArgs, IColumnResizeEventArgs, IColumnSelectionEventArgs, IColumnVisibilityChangedEventArgs, IColumnVisibilityChangingEventArgs, IFilteringEventArgs, IGridCellEventArgs, IGridClipboardEvent, IGridContextMenuEventArgs, IGridEditDoneEventArgs, IGridEditEventArgs, IGridFormGroupCreatedEventArgs, IGridKeydownEventArgs, IGridRowEventArgs, IGridScrollEventArgs, IGridToolbarExportEventArgs, IGridValidationStatusEventArgs, IGX_GRID_SERVICE_BASE, IgxAdvancedFilteringDialogComponent, IgxCell, IgxColumnComponent, IgxColumnGroupComponent, IgxColumnResizingService, IgxDragIndicatorIconDirective, IgxEditRow, IgxExcelStyleHeaderIconDirective, IgxExcelStyleLoadingValuesTemplateDirective, IgxFilteringService, IgxGridBodyDirective, IgxGridCellComponent, IgxGridColumnResizerComponent, IgxGridEmptyTemplateContext, IgxGridEmptyTemplateDirective, IgxGridExcelStyleFilteringComponent, IgxGridFilteringCellComponent, IgxGridFilteringRowComponent, IgxGridHeaderComponent, IgxGridHeaderGroupComponent, IgxGridHeaderRowComponent, IgxGridHeaderTemplateContext, IgxGridLoadingTemplateDirective, IgxGridNavigationService, IgxGridPinningActionsComponent, IgxGridRowDragGhostContext, IgxGridRowEditActionsTemplateContext, IgxGridRowEditTemplateContext, IgxGridRowEditTextTemplateContext, IgxGridRowTemplateContext, IgxGridSelectionService, IgxGridSummaryService, IgxGridTemplateContext, IgxGridToolbarComponent, IgxGridValidationService, IgxHeaderCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxHeadSelectorDirective, IgxHeadSelectorTemplateContext, IgxRowAddTextDirective, IgxRowCollapsedIndicatorDirective, IgxRowDirective, IgxRowDragGhostDirective, IgxRowEditActionsDirective, IgxRowEditTabStopDirective, IgxRowEditTemplateDirective, IgxRowEditTextDirective, IgxRowExpandedIndicatorDirective, IgxRowSelectorDirective, IgxRowSelectorTemplateContext, IgxSortAscendingHeaderIconDirective, IgxSortDescendingHeaderIconDirective, IgxSortHeaderIconDirective, IgxSummaryRowComponent, IgxToolbarToken, IPinColumnCancellableEventArgs, IPinColumnEventArgs, IPinningConfig, IPinRowEventArgs, IRowDataCancelableEventArgs, IRowDataEventArgs, IRowDragEndEventArgs, IRowDragStartEventArgs, IRowSelectionEventArgs, IRowToggleEventArgs, ISearchInfo, ISizeInfo, ISortingEventArgs, RowEditPositionStrategy, RowPinningPosition, RowType, WatchChanges } from 'igniteui-angular/grids/core';
import { getCurrentI18n, getNumberFormatter, IResourceChangeEventArgs, } from 'igniteui-i18n-core';
import { I18N_FORMATTER, IgxStylesRegistrar } from 'igniteui-angular/core';
Expand Down Expand Up @@ -3467,7 +3473,12 @@ export abstract class IgxGridBaseDirective implements GridType,
}

constructor() {
inject(IgxStylesRegistrar).register(GRID_STYLES_ID, GRID_BASE_CSS);
const styles = inject(IgxStylesRegistrar);
styles.register(GRID_STYLES_ID, GRID_BASE_CSS);
styles.retain(
IgxBadgeComponent, IgxChipComponent, IgxCheckboxComponent,
IgxInputGroupComponent, IgxDatePickerComponent, IgxCircularProgressBarComponent
);
this.initLocale();
this._transactions = this.transactionFactory.create(TRANSACTION_TYPE.None);
this._transactions.cloneStrategy = this.dataCloneStrategy;
Expand Down
4 changes: 3 additions & 1 deletion projects/igniteui-angular/grids/themes/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2564,7 +2564,9 @@
flex-flow: column nowrap;
position: relative;

:where(.igx-grid-th, .igx-grid-thead__group) {
// Split :where()s, a multi-argument subject defeats Chromium's rule bucketing.
:where(.igx-grid-th),
:where(.igx-grid-thead__group) {
flex: 1 1 auto;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Component, ViewChild, ElementRef, inject, ChangeDetectionStrategy } from '@angular/core';
import { Component, ViewChild, ElementRef, inject, ChangeDetectionStrategy, createComponent, EnvironmentInjector } from '@angular/core';
import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { IgxInputGroupComponent } from './input-group.component';
import { UIInteractions } from '../../../test-utils/ui-interactions.spec';
import { IgxInputDirective, IgxPrefixDirective, IgxSuffixDirective } from '../public_api';
import { IGX_INPUT_GROUP_TYPE, IgxInputGroupType } from './inputGroupType';
import { InputResourceStringsEN, changei18n } from 'igniteui-angular/core';
import { InputResourceStringsEN, changei18n, THEME_TOKEN } from 'igniteui-angular/core';
import { config } from 'rxjs';

const INPUT_GROUP_CSS_CLASS = 'igx-input-group';
const INPUT_GROUP_BOX_CSS_CLASS = 'igx-input-group--box';
Expand Down Expand Up @@ -259,6 +260,24 @@ describe('IgxInputGroup', () => {
expect(fixture.componentInstance.materialGroup.isTypeIndigo).toBe(false);
}));

it('should not throw on theme change when no igxInput is projected', async () => {
const errors: unknown[] = [];
const previous = config.onUnhandledError;
config.onUnhandledError = error => errors.push(error);
const token = TestBed.inject(THEME_TOKEN);
// Mirrors the detached instance IgxStylesRegistrar.retain() creates
const ref = createComponent(IgxInputGroupComponent, { environmentInjector: TestBed.inject(EnvironmentInjector) });

try {
token.set(token.theme === 'fluent' ? 'material' : 'fluent');
await new Promise(resolve => setTimeout(resolve));
expect(errors).toEqual([]);
} finally {
config.onUnhandledError = previous;
ref.destroy();
}
});

describe('Resource Strings', () => {
it('should return full resource strings when partial resourceStrings are set', () => {
const fix = TestBed.createComponent(InputGroupComponent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,13 @@ export class IgxInputGroupComponent implements IgxInputGroupBase, AfterContentCh
/** @hidden @internal */
@HostBinding('class.igx-input-group--file')
public get isFileType() {
return this.input.type === 'file';
return this.input?.type === 'file';
}

/** @hidden @internal */
@HostBinding('class.igx-file-input')
public get isFileInput() {
return this.input.type === 'file';
return this.input?.type === 'file';
}

/** @hidden @internal */
Expand All @@ -419,7 +419,7 @@ export class IgxInputGroupComponent implements IgxInputGroupBase, AfterContentCh

/** @hidden @internal */
public get fileNames() {
return this.input.fileNames || this.resourceStrings.igx_input_file_placeholder;
return this.input?.fileNames || this.resourceStrings.igx_input_file_placeholder;
}

/**
Expand Down
Loading