diff --git a/src/components/cell.ts b/src/components/cell.ts index b9219ac..058c2a2 100644 --- a/src/components/cell.ts +++ b/src/components/cell.ts @@ -1,6 +1,7 @@ -import { html, LitElement } from 'lit'; +import { html, LitElement, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; import { cache } from 'lit/directives/cache.js'; +import { AdoptedStylesController } from '../controllers/root-styles.js'; import { registerComponent } from '../internal/register.js'; import { GRID_CELL_TAG } from '../internal/tags.js'; import type { ColumnConfiguration, IgcCellContext, PropertyType } from '../internal/types.js'; @@ -21,6 +22,11 @@ export default class IgcGridLiteCell extends LitElement { registerComponent(IgcGridLiteCell); } + private readonly _adoptedStylesController = new AdoptedStylesController(this); + + @property({ attribute: false }) + public adoptRootStyles = false; + /** * The value which will be rendered by the component. */ @@ -61,6 +67,16 @@ export default class IgcGridLiteCell extends LitElement { } as unknown as IgcCellContext; } + protected override update(props: PropertyValues): void { + if (props.has('adoptRootStyles')) { + this._adoptedStylesController.shouldAdoptStyles( + this.adoptRootStyles && this.cellTemplate != null + ); + } + + super.update(props); + } + protected override render() { return html`${cache( this.cellTemplate diff --git a/src/components/grid.ts b/src/components/grid.ts index 0ad16c6..72ef4e0 100644 --- a/src/components/grid.ts +++ b/src/components/grid.ts @@ -196,6 +196,9 @@ export class IgcGridLite extends EventEmitterBase extends EventEmitterBase extends EventEmitterBase `; diff --git a/src/components/header-row.ts b/src/components/header-row.ts index c0a3af2..e44e034 100644 --- a/src/components/header-row.ts +++ b/src/components/header-row.ts @@ -1,7 +1,7 @@ import { consume } from '@lit/context'; -import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { html, LitElement, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; -import { map } from 'lit/directives/map.js'; +import { repeat } from 'lit/directives/repeat.js'; import type { StateController } from '../controllers/state.js'; import { GRID_STATE_CONTEXT } from '../internal/context.js'; import { getElementFromEventPath } from '../internal/element-from-event-path.js'; @@ -25,6 +25,9 @@ export default class IgcGridLiteHeaderRow extends LitElement { @consume({ context: GRID_STATE_CONTEXT, subscribe: true }) private readonly _state?: StateController; + @property({ attribute: false }) + public adoptRootStyles = false; + @property({ attribute: false }) public columns: ColumnConfiguration[] = []; @@ -54,17 +57,21 @@ export default class IgcGridLiteHeaderRow extends LitElement { protected override render() { const filterRow = this._state?.filtering.filterRow; + const columns = this.columns.filter((column) => !column.hidden); - return html`${map(this.columns, (column) => - column.hidden - ? nothing - : html` - - ` - )}`; + return html` + ${repeat( + columns, + (column) => column, + (column) => html` + + ` + )} + `; } } diff --git a/src/components/header.ts b/src/components/header.ts index ecf31d5..0276306 100644 --- a/src/components/header.ts +++ b/src/components/header.ts @@ -3,8 +3,9 @@ import { ΞaddThemingController as addThemingController, IgcIconComponent, } from 'igniteui-webcomponents'; -import { html, LitElement, nothing } from 'lit'; +import { html, LitElement, nothing, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; +import { AdoptedStylesController } from '../controllers/root-styles.js'; import type { StateController } from '../controllers/state.js'; import { MIN_COL_RESIZE_WIDTH, @@ -30,6 +31,8 @@ export default class IgcGridLiteHeader extends LitElement { registerComponent(IgcGridLiteHeader, IgcIconComponent); } + private readonly _adoptedStylesController = new AdoptedStylesController(this); + protected get context(): IgcHeaderContext { return { parent: this, @@ -52,10 +55,32 @@ export default class IgcGridLiteHeader extends LitElement { @property({ attribute: false }) public column!: ColumnConfiguration; + @property({ attribute: false }) + public adoptRootStyles = false; + constructor() { super(); - addThemingController(this, all); + addThemingController(this, all, { + themeChange: this._handleThemeChange, + }); + } + + protected override update(props: PropertyValues): void { + if (props.has('adoptRootStyles')) { + this._adoptedStylesController.shouldAdoptStyles( + this.adoptRootStyles && this.column.headerTemplate != null + ); + } + + super.update(props); + } + + private _handleThemeChange() { + AdoptedStylesController.invalidateCache(this.ownerDocument); + this._adoptedStylesController.shouldAdoptStyles( + this.adoptRootStyles && this.column.headerTemplate != null + ); } #addResizeEventHandlers() { diff --git a/src/components/row.ts b/src/components/row.ts index 73e10cf..f0440c3 100644 --- a/src/components/row.ts +++ b/src/components/row.ts @@ -1,6 +1,6 @@ -import { html, LitElement, nothing } from 'lit'; +import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; -import { map } from 'lit/directives/map.js'; +import { repeat } from 'lit/directives/repeat.js'; import { registerComponent } from '../internal/register.js'; import { GRID_ROW_TAG } from '../internal/tags.js'; import type { ActiveNode, ColumnConfiguration } from '../internal/types.js'; @@ -21,6 +21,9 @@ export default class IgcGridLiteRow extends LitElement { registerComponent(IgcGridLiteRow, IgcGridLiteCell); } + @property({ attribute: false }) + public adoptRootStyles = false; + @property({ attribute: false }) public data?: T; @@ -43,19 +46,24 @@ export default class IgcGridLiteRow extends LitElement { const { column: key, row: index } = this.activeNode ?? {}; const data = this.data ?? ({} as T); + const columns = this.columns.filter((column) => !column.hidden); + return html` - ${map(this.columns, (column) => - column.hidden - ? nothing - : html`} - .rowIndex=${this.index} - .value=${resolveFieldValue(data, column.field)} - >` + ${repeat( + columns, + (column) => column, + (column) => html` + } + .rowIndex=${this.index} + .value=${resolveFieldValue(data, column.field)} + > + ` )} `; } diff --git a/src/controllers/root-styles.ts b/src/controllers/root-styles.ts new file mode 100644 index 0000000..19d9cee --- /dev/null +++ b/src/controllers/root-styles.ts @@ -0,0 +1,95 @@ +import { + adoptStyles, + type LitElement, + type ReactiveController, + type ReactiveControllerHost, +} from 'lit'; + +export class AdoptedStylesController implements ReactiveController { + private static _cachedSheets = new WeakMap(); + + private readonly _host: ReactiveControllerHost & LitElement; + private _hasAdoptedStyles = false; + + public get hasAdoptedStyles(): boolean { + return this._hasAdoptedStyles; + } + + public static invalidateCache(owner?: Document): void { + AdoptedStylesController._cachedSheets.delete(owner ?? document); + } + + public constructor(host: ReactiveControllerHost & LitElement) { + this._host = host; + host.addController(this); + } + + public shouldAdoptStyles(condition: boolean): void { + condition ? this._adoptRootStyles() : this._clearAdoptedStyles(); + } + + /** @internal */ + public hostDisconnected(): void { + this._clearAdoptedStyles(); + } + + private _adoptRootStyles(): void { + const ownerDocument = this._host.ownerDocument; + + if (!AdoptedStylesController._cachedSheets.has(ownerDocument)) { + AdoptedStylesController._cachedSheets.set( + ownerDocument, + this._cloneDocumentStyleSheets(ownerDocument) + ); + } + + const ctor = this._host.constructor as typeof LitElement; + adoptStyles(this._host.shadowRoot!, [ + ...ctor.elementStyles, + ...AdoptedStylesController._cachedSheets.get(ownerDocument)!, + ]); + this._hasAdoptedStyles = true; + } + + private _cloneDocumentStyleSheets(ownerDocument: Document): CSSStyleSheet[] { + const sheets: CSSStyleSheet[] = []; + + for (const sheet of ownerDocument.styleSheets) { + try { + const constructed = new CSSStyleSheet(); + let hasRules = false; + + for (const rule of sheet.cssRules) { + if (rule instanceof CSSImportRule) { + continue; + } + + try { + constructed.insertRule(rule.cssText); + hasRules = true; + } catch { + // Ignore rules that can't be adopted. + } + } + + if (hasRules) { + sheets.push(constructed); + } + } catch { + // Ignore stylesheets we can't access due to CORS. + } + } + return sheets; + } + + private _clearAdoptedStyles(): void { + const shadowRoot = this._host.shadowRoot; + if (shadowRoot) { + shadowRoot.adoptedStyleSheets = shadowRoot.adoptedStyleSheets.filter( + (sheet) => + !AdoptedStylesController._cachedSheets.get(this._host.ownerDocument)?.includes(sheet) + ); + } + this._hasAdoptedStyles = false; + } +} diff --git a/test/adopt-root-styles.test.ts b/test/adopt-root-styles.test.ts new file mode 100644 index 0000000..436ee11 --- /dev/null +++ b/test/adopt-root-styles.test.ts @@ -0,0 +1,411 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { html as litHtml } from 'lit'; +import type IgcGridLiteCell from '../src/components/cell.js'; +import type { IgcGridLite } from '../src/components/grid.js'; +import type IgcGridLiteHeader from '../src/components/header.js'; +import type { IgcCellContext, IgcHeaderContext } from '../src/internal/types.js'; +import GridTestFixture from './utils/grid-fixture.js'; +import data, { type TestData } from './utils/test-data.js'; + +class AdoptRootStylesFixture extends GridTestFixture { + private styleElement?: HTMLStyleElement; + + public override async setUp() { + // Add styles to the document before setting up the grid + this.styleElement = document.createElement('style'); + this.styleElement.textContent = ` + .custom-cell-class { + color: rgb(255, 0, 0); + font-weight: bold; + } + + .custom-header-class { + color: rgb(0, 255, 0); + text-transform: uppercase; + } + `; + document.head.appendChild(this.styleElement); + + await super.setUp(); + } + + public override tearDown() { + if (this.styleElement) { + this.styleElement.remove(); + } + return super.tearDown(); + } + + public override setupTemplate() { + return html` + + ${this.columnConfig.map( + (col) => + html`` + )} + + `; + } +} + +class NoAdoptRootStylesFixture extends GridTestFixture { + private styleElement?: HTMLStyleElement; + + public override async setUp() { + // Add styles to the document before setting up the grid + this.styleElement = document.createElement('style'); + this.styleElement.textContent = ` + .custom-cell-class { + color: rgb(255, 0, 0); + font-weight: bold; + } + + .custom-header-class { + color: rgb(0, 255, 0); + text-transform: uppercase; + } + `; + document.head.appendChild(this.styleElement); + + await super.setUp(); + } + + public override tearDown() { + if (this.styleElement) { + this.styleElement.remove(); + } + return super.tearDown(); + } + + public override setupTemplate() { + return html` + + ${this.columnConfig.map( + (col) => + html`` + )} + + `; + } +} + +const adoptRootStylesTDD = new AdoptRootStylesFixture(data); +const noAdoptRootStylesTDD = new NoAdoptRootStylesFixture(data); + +function createParentNode() { + const parentNode = document.createElement('div'); + Object.assign(parentNode.style, { height: '800px' }); + return parentNode; +} + +describe('Grid adopt-root-styles property', () => { + describe('With cell templates', () => { + beforeEach(async () => { + adoptRootStylesTDD.columnConfig = [ + { + field: 'name', + cellTemplate: (ctx: IgcCellContext) => + litHtml`
${ctx.value}
`, + }, + { field: 'id' }, + ]; + await adoptRootStylesTDD.setUp(); + }); + + afterEach(() => adoptRootStylesTDD.tearDown()); + + it('should adopt root styles to cell shadow DOM when adopt-root-styles is true', async () => { + const cell = adoptRootStylesTDD.rows.first.cells.get(0); + const cellElement = cell.element; + + expect(cellElement.adoptRootStyles).to.be.true; + + const customDiv = cellElement.shadowRoot!.querySelector('.custom-cell-class'); + expect(customDiv).to.exist; + + const computedStyle = window.getComputedStyle(customDiv!); + expect(computedStyle.color).to.equal('rgb(255, 0, 0)'); + expect(computedStyle.fontWeight).to.equal('700'); + }); + + it('should have adopted styles in cell component', async () => { + const cell = adoptRootStylesTDD.rows.first.cells.get(0); + const cellElement = cell.element as IgcGridLiteCell; + + // @ts-expect-error - Accessing private controller for testing + expect(cellElement._adoptedStylesController.hasAdoptedStyles).to.be.true; + }); + + it('should not affect cells without templates', async () => { + const cellWithoutTemplate = adoptRootStylesTDD.rows.first.cells.get(1); + const cellElement = cellWithoutTemplate.element as IgcGridLiteCell; + + // @ts-expect-error - Accessing private controller for testing + expect(cellElement._adoptedStylesController.hasAdoptedStyles).to.be.false; + }); + }); + + describe('With header templates', () => { + beforeEach(async () => { + adoptRootStylesTDD.columnConfig = [ + { + field: 'name', + headerTemplate: (ctx: IgcHeaderContext) => + litHtml`
${ctx.column.header || ctx.column.field}
`, + }, + { field: 'id' }, + ]; + await adoptRootStylesTDD.setUp(); + }); + + afterEach(() => adoptRootStylesTDD.tearDown()); + + it('should adopt root styles to header shadow DOM when adopt-root-styles is true', async () => { + const header = adoptRootStylesTDD.headers.get('name'); + const headerElement = header.element; + + expect(headerElement.adoptRootStyles).to.be.true; + + const customDiv = headerElement.shadowRoot!.querySelector('.custom-header-class'); + expect(customDiv).to.exist; + + const computedStyle = window.getComputedStyle(customDiv!); + expect(computedStyle.color).to.equal('rgb(0, 255, 0)'); + expect(computedStyle.textTransform).to.equal('uppercase'); + }); + + it('should have adopted styles in header component', async () => { + const header = adoptRootStylesTDD.headers.get('name'); + const headerElement = header.element as IgcGridLiteHeader; + + // @ts-expect-error - Accessing private controller for testing + expect(headerElement._adoptedStylesController.hasAdoptedStyles).to.be.true; + }); + + it('should not affect headers without templates', async () => { + const headerWithoutTemplate = adoptRootStylesTDD.headers.get('id'); + const headerElement = headerWithoutTemplate.element as IgcGridLiteHeader; + + // @ts-expect-error - Accessing private controller for testing + expect(headerElement._adoptedStylesController.hasAdoptedStyles).to.be.false; + }); + }); + + describe('With both cell and header templates', () => { + beforeEach(async () => { + adoptRootStylesTDD.columnConfig = [ + { + field: 'name', + cellTemplate: (ctx: IgcCellContext) => + litHtml`
${ctx.value}
`, + headerTemplate: (ctx: IgcHeaderContext) => + litHtml`
${ctx.column.header || ctx.column.field}
`, + }, + { field: 'id' }, + ]; + await adoptRootStylesTDD.setUp(); + }); + + afterEach(() => adoptRootStylesTDD.tearDown()); + + it('should adopt root styles to both cell and header shadow DOMs', async () => { + const cell = adoptRootStylesTDD.rows.first.cells.get(0); + const header = adoptRootStylesTDD.headers.get('name'); + + const cellElement = cell.element; + const headerElement = header.element; + + expect(cellElement.adoptRootStyles).to.be.true; + expect(headerElement.adoptRootStyles).to.be.true; + + const customCellDiv = cellElement.shadowRoot!.querySelector('.custom-cell-class'); + const customHeaderDiv = headerElement.shadowRoot!.querySelector('.custom-header-class'); + + expect(customCellDiv).to.exist; + expect(customHeaderDiv).to.exist; + + const cellComputedStyle = window.getComputedStyle(customCellDiv!); + const headerComputedStyle = window.getComputedStyle(customHeaderDiv!); + + expect(cellComputedStyle.color).to.equal('rgb(255, 0, 0)'); + expect(headerComputedStyle.color).to.equal('rgb(0, 255, 0)'); + }); + }); + + describe('Without adopt-root-styles property', () => { + beforeEach(async () => { + noAdoptRootStylesTDD.columnConfig = [ + { + field: 'name', + cellTemplate: (ctx: IgcCellContext) => + litHtml`
${ctx.value}
`, + headerTemplate: (ctx: IgcHeaderContext) => + litHtml`
${ctx.column.header || ctx.column.field}
`, + }, + ]; + await noAdoptRootStylesTDD.setUp(); + }); + + afterEach(() => noAdoptRootStylesTDD.tearDown()); + + it('should not adopt root styles when adopt-root-styles is false', async () => { + const cell = noAdoptRootStylesTDD.rows.first.cells.get(0); + const header = noAdoptRootStylesTDD.headers.get('name'); + + const cellElement = cell.element as IgcGridLiteCell; + const headerElement = header.element as IgcGridLiteHeader; + + expect(cellElement.adoptRootStyles).to.be.false; + expect(headerElement.adoptRootStyles).to.be.false; + + // @ts-expect-error - Accessing private controller for testing + expect(cellElement._adoptedStylesController.hasAdoptedStyles).to.be.false; + // @ts-expect-error - Accessing private controller for testing + expect(headerElement._adoptedStylesController.hasAdoptedStyles).to.be.false; + }); + + it('should not apply document styles to templated cells when adopt-root-styles is false', async () => { + const cell = noAdoptRootStylesTDD.rows.first.cells.get(0); + const cellElement = cell.element; + + const customDiv = cellElement.shadowRoot!.querySelector('.custom-cell-class'); + expect(customDiv).to.exist; + + const computedStyle = window.getComputedStyle(customDiv!); + // Without adopted styles, the custom class styles won't apply + expect(computedStyle.color).to.not.equal('rgb(255, 0, 0)'); + }); + + it('should not apply document styles to templated headers when adopt-root-styles is false', async () => { + const header = noAdoptRootStylesTDD.headers.get('name'); + const headerElement = header.element; + + const customDiv = headerElement.shadowRoot!.querySelector('.custom-header-class'); + expect(customDiv).to.exist; + + const computedStyle = window.getComputedStyle(customDiv!); + // Without adopted styles, the custom class styles won't apply + expect(computedStyle.color).to.not.equal('rgb(0, 255, 0)'); + }); + }); + + describe('Dynamic property toggling', () => { + let grid: IgcGridLite; + let styleElement: HTMLStyleElement; + + beforeEach(async () => { + // Add styles to the document + styleElement = document.createElement('style'); + styleElement.textContent = ` + .dynamic-cell-class { + color: rgb(0, 0, 255); + } + `; + document.head.appendChild(styleElement); + + grid = await fixture( + html` + + ) => + litHtml`
${ctx.value}
`} + >
+
+ `, + { parentNode: createParentNode() } + ); + await grid.updateComplete; + + // Wait for virtualizer to complete layout + const virtualizer = grid.renderRoot.querySelector('igc-virtualizer'); + if (virtualizer) { + await virtualizer.layoutComplete; + } + }); + + afterEach(() => { + styleElement.remove(); + }); + + it('should adopt styles when property is set to true dynamically', async () => { + // Initially false + let cell = grid.rows[0]!.cells[0]; + expect(cell.adoptRootStyles).to.be.false; + // @ts-expect-error - Accessing private controller for testing + expect(cell._adoptedStylesController.hasAdoptedStyles).to.be.false; + + // Set adopt-root-styles to true + grid.adoptRootStyles = true; + await grid.updateComplete; + + // Wait for rows to update + const row = grid.rows[0]; + await row.updateComplete; + + // Get the cell again after the update + cell = grid.rows[0]!.cells[0]; + expect(cell.adoptRootStyles).to.be.true; + // @ts-expect-error - Accessing private controller for testing + expect(cell._adoptedStylesController.hasAdoptedStyles).to.be.true; + + const customDiv = cell.shadowRoot!.querySelector('.dynamic-cell-class'); + const computedStyle = window.getComputedStyle(customDiv!); + expect(computedStyle.color).to.equal('rgb(0, 0, 255)'); + }); + + it('should remove adopted styles when property is set to false dynamically', async () => { + // Start with adopt-root-styles true + grid.adoptRootStyles = true; + await grid.updateComplete; + + // Wait for rows to update + let row = grid.rows[0]; + await row.updateComplete; + + let cell = grid.rows[0]!.cells[0]; + expect(cell.adoptRootStyles).to.be.true; + // @ts-expect-error - Accessing private controller for testing + expect(cell._adoptedStylesController.hasAdoptedStyles).to.be.true; + + // Set adopt-root-styles to false + grid.adoptRootStyles = false; + await grid.updateComplete; + + // Wait for rows to update + row = grid.rows[0]; + await row.updateComplete; + + // Get the cell again after the update + cell = grid.rows[0]!.cells[0]; + expect(cell.adoptRootStyles).to.be.false; + // @ts-expect-error - Accessing private controller for testing + expect(cell._adoptedStylesController.hasAdoptedStyles).to.be.false; + }); + }); +});