Skip to content
Merged
19 changes: 19 additions & 0 deletions packages/icon/src/vaadin-icon-font-size-mixin.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @license
* Copyright (c) 2021 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import type { Constructor } from '@open-wc/dedupe-mixin';

/**
* Mixin which enables the font icon sizing fallback for browsers that do not support CSS Container Queries.
* In older versions of Safari, it didn't support Container Queries units used in pseudo-elements. It has been fixed in
* recent versions, but there's an regression in Safari 26, which caused the same issue to happen when the icon is
* attached to an element with shadow root.
* The mixin does nothing if the browser supports CSS Container Query units for pseudo elements.
*/
export declare function IconFontSizeMixin<T extends Constructor<HTMLElement>>(
base: T,
): Constructor<IconFontSizeMixinClass> & T;

export declare class IconFontSizeMixinClass {}
64 changes: 64 additions & 0 deletions packages/icon/src/vaadin-icon-font-size-mixin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @license
* Copyright (c) 2021 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { css } from 'lit';
import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';
import { needsFontIconSizingFallback } from './vaadin-icon-helpers.js';

const usesFontIconSizingFallback = needsFontIconSizingFallback();

/**
* Mixin which enables the font icon sizing fallback for browsers that do not support CSS Container Queries.
* In older versions of Safari, it didn't support Container Queries units used in pseudo-elements. It has been fixed in
* recent versions, but there's an regression in Safari 26, which caused the same issue to happen when the icon is
* attached to an element with shadow root.
* The mixin does nothing if the browser supports CSS Container Query units for pseudo elements.
*
* @polymerMixin
*/
export const IconFontSizeMixin = (superclass) =>
!usesFontIconSizingFallback
? superclass
: class extends ResizeMixin(superclass) {
static get styles() {
return css`
:host::after,
:host::before {
font-size: var(--vaadin-icon-visual-size, var(--_vaadin-font-icon-size));
}
`;
}

/** @protected */
updated(props) {
super.updated(props);

if (props.has('char') || props.has('iconClass') || props.has('ligature')) {
this.__updateFontIconSize();
}
}

/**
* @protected
* @override
*/
_onResize() {
// Update when the element is resized
this.__updateFontIconSize();
}

/**
* Updates the --_vaadin-font-icon-size CSS variable value if font icons are used.
*
* @private
*/
__updateFontIconSize() {
if (this.char || this.iconClass || this.ligature) {
const { paddingTop, paddingBottom, height } = getComputedStyle(this);
const fontIconSize = parseFloat(height) - parseFloat(paddingTop) - parseFloat(paddingBottom);
this.style.setProperty('--_vaadin-font-icon-size', `${fontIconSize}px`);
}
}
};
61 changes: 61 additions & 0 deletions packages/icon/src/vaadin-icon-helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @license
* Copyright (c) 2021 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { isSafari } from '@vaadin/component-base/src/browser-utils.js';

/**
* Checks if the current browser supports CSS Container Query units for pseudo elements.
* i.e. if the fix for https://bugs.webkit.org/show_bug.cgi?id=253939 is available.
*/
export function supportsCQUnitsForPseudoElements() {
const testStyle = document.createElement('style');
testStyle.textContent = `
.vaadin-icon-test-element {
container-type: size;
height: 2px;
visibility: hidden;
position: fixed;
}

.vaadin-icon-test-element::before {
content: '';
display: block;
height: 100cqh;
`;
const testElement = document.createElement('div');
testElement.classList.add('vaadin-icon-test-element');

const shadowParent = document.createElement('div');
shadowParent.attachShadow({ mode: 'open' });
shadowParent.shadowRoot.innerHTML = '<slot></slot>';
shadowParent.append(testElement.cloneNode());

document.body.append(testStyle, testElement, shadowParent);

const needsFallback = [...document.querySelectorAll('.vaadin-icon-test-element')].find(
(el) => getComputedStyle(el, '::before').height !== '2px',
);

testStyle.remove();
testElement.remove();
shadowParent.remove();
return !needsFallback;
}

/**
* Checks if the current browser needs a fallback for sizing font icons instead of relying on CSS Container Queries.
*/
export function needsFontIconSizingFallback() {
if (!CSS.supports('container-type: inline-size')) {
// The browser does not support CSS Container Queries at all.
return true;
}
if (!isSafari) {
// Browsers other than Safari support CSS Container Queries as expected.
return false;
}
// Check if the browser does not support CSS Container Query units for pseudo elements.
return !supportsCQUnitsForPseudoElements();
}
3 changes: 2 additions & 1 deletion packages/icon/src/vaadin-icon-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
*/
import type { Constructor } from '@open-wc/dedupe-mixin';
import type { SlotStylesMixinClass } from '@vaadin/component-base/src/slot-styles-mixin.js';
import type { IconFontSizeMixinClass } from './vaadin-icon-font-size-mixin.js';
import type { IconSvgLiteral } from './vaadin-icon-svg.js';

/**
* A mixin providing common icon functionality.
*/
export declare function IconMixin<T extends Constructor<HTMLElement>>(
base: T,
): Constructor<IconMixinClass> & Constructor<SlotStylesMixinClass> & T;
): Constructor<IconFontSizeMixinClass> & Constructor<IconMixinClass> & Constructor<SlotStylesMixinClass> & T;

export declare class IconMixinClass {
/**
Expand Down
4 changes: 3 additions & 1 deletion packages/icon/src/vaadin-icon-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js';
import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js';
import { IconFontSizeMixin } from './vaadin-icon-font-size-mixin.js';
import { unsafeSvgLiteral } from './vaadin-icon-svg.js';

const srcCache = new Map();
Expand All @@ -14,9 +15,10 @@ const Iconset = customElements.get('vaadin-iconset');
/**
* @polymerMixin
* @mixes SlotStylesMixin
* @mixes IconFontSizeMixin
*/
export const IconMixin = (superClass) =>
class extends SlotStylesMixin(superClass) {
class extends IconFontSizeMixin(SlotStylesMixin(superClass)) {
static get properties() {
return {
/**
Expand Down
3 changes: 2 additions & 1 deletion packages/icon/src/vaadin-icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ class Icon extends IconMixin(ElementMixin(ThemableMixin(PolylitMixin(LumoInjecti
}

static get styles() {
return iconStyles;
// Apply `super.styles` only if the fallback is used
return [iconStyles, super.styles].filter(Boolean);
}

static get lumoInjector() {
Expand Down
83 changes: 82 additions & 1 deletion packages/icon/test/icon-font.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect } from '@vaadin/chai-plugins';
import { fixtureSync, nextFrame, nextResize } from '@vaadin/testing-helpers';
import { fixtureSync, isChrome, nextFrame, nextResize } from '@vaadin/testing-helpers';
import '../src/vaadin-icon.js';
import { needsFontIconSizingFallback, supportsCQUnitsForPseudoElements } from '../src/vaadin-icon-helpers.js';
import { iconFontCss } from './test-icon-font.js';

describe('vaadin-icon - icon fonts', () => {
Expand Down Expand Up @@ -267,4 +268,84 @@ describe('vaadin-icon - icon fonts', () => {
expect(['"My icons 6"', 'My icons 6']).to.include(fontIconStyle.fontFamily);
});
});

// These tests make sure that the heavy container query fallback is only used
// when font icons are used.
describe('container query fallback', () => {
// Tests for browsers that require the fallback
const fallBackIt = needsFontIconSizingFallback() ? it : it.skip;
// Tests for browsers that we know for sure not to require the fallback
const supportedIt = isChrome ? it : it.skip;

let icon;

supportedIt('should support CQ width units on pseudo elements', () => {
expect(supportsCQUnitsForPseudoElements()).to.be.true;
});

supportedIt('should not need the fallback', () => {
expect(needsFontIconSizingFallback()).to.be.false;
});

fallBackIt('should not support CQ width units on pseudo elements', () => {
expect(supportsCQUnitsForPseudoElements()).to.be.false;
});

fallBackIt('should have the custom property (iconClass)', async () => {
icon = fixtureSync('<vaadin-icon icon-class="foo" style="--vaadin-icon-size: 24px"></vaadin-icon>');
await nextFrame();
expect(icon.style.getPropertyValue('--_vaadin-font-icon-size')).to.equal('24px');
});

fallBackIt('should have the custom property (char)', async () => {
icon = fixtureSync('<vaadin-icon char="foo" style="--vaadin-icon-size: 24px"></vaadin-icon>');
await nextFrame();
expect(icon.style.getPropertyValue('--_vaadin-font-icon-size')).to.equal('24px');
});

fallBackIt('should not have the custom property', async () => {
icon = fixtureSync('<vaadin-icon></vaadin-icon>');
await nextFrame();
expect(icon.style.getPropertyValue('--_vaadin-font-icon-size')).to.equal('');
});

fallBackIt('should set the custom property', async () => {
icon = fixtureSync('<vaadin-icon style="--vaadin-icon-size: 24px"></vaadin-icon>');
await nextFrame();
icon.iconClass = 'foo';
expect(icon.style.getPropertyValue('--_vaadin-font-icon-size')).to.equal('24px');
});

fallBackIt('should update the custom property', async () => {
icon = fixtureSync('<vaadin-icon icon-class="foo"></vaadin-icon>');
await nextFrame();
icon.style.width = '100px';
icon.style.height = '100px';
await nextResize(icon);
expect(icon.style.getPropertyValue('--_vaadin-font-icon-size')).to.equal('100px');
});

fallBackIt('should not update the custom property', async () => {
icon = fixtureSync('<vaadin-icon></vaadin-icon>');
await nextFrame();
icon.style.width = '100px';
icon.style.height = '100px';
await nextFrame(icon);
await nextFrame(icon);
expect(icon.style.getPropertyValue('--_vaadin-font-icon-size')).to.equal('');
});

fallBackIt('should have the same height as the host with shadow root', async () => {
icon = fixtureSync('<vaadin-icon char="foo" style="--vaadin-icon-size: 24px"></vaadin-icon>');
const parent = fixtureSync('<div></div>');
parent.attachShadow({ mode: 'open' });
parent.shadowRoot.innerHTML = '<slot></slot>';

parent.append(icon);
await nextResize(icon);

const fontIconStyle = getComputedStyle(icon, ':before');
expect(parseInt(fontIconStyle.height)).to.be.closeTo(24, 1);
});
});
});