From 2202ea9642d3198618d4fc732fca6be576979dd3 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 15 Apr 2025 17:56:38 -0400 Subject: [PATCH] CSS custom properties to set certain component attributes (#447) --- docs/docs/components/icon.md | 80 ++++++++++++++ docs/docs/test/benchmark.njk | 71 +++++++++++++ package-lock.json | 9 +- package.json | 3 +- src/components/icon/icon.ts | 12 ++- src/internal/webawesome-element.ts | 161 ++++++++++++++++++++++++++++- 6 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 docs/docs/test/benchmark.njk diff --git a/docs/docs/components/icon.md b/docs/docs/components/icon.md index 6b3e850c1..cd0ae2968 100644 --- a/docs/docs/components/icon.md +++ b/docs/docs/components/icon.md @@ -24,6 +24,86 @@ Many Font Awesome Pro icon families have variants such as `thin`, `light`, `regu ``` +### Setting defaults via CSS + +You can use certain CSS custom properties to set icon defaults, not just on the icon itself, but any ancestor. +This can be useful when you want certain parameters to vary based on context, e.g. icons inside callouts or all icons for a given theme. + +:::warning +These CSS properties are intended to set **defaults**, and thus only make a difference when the corresponding attributes are not set. +In future versions of Web Awesome, we may change this behavior to allow CSS properties to override attributes if `!important` is used. +::: + +For example, here is how you can use CSS custom properties to set a default icon for each type of callout: + +```html {.example} + + + + This is a normal callout. + + + + + This is a callout with an explicit icon, which overrides these defaults. + + + + + + Here be dragons. + + + + + + Here be more dragons. + + + + + + Success! + + + +``` + +You can even set icons dynamically, as a response to user interaction or media queries. +For example, here's how we can change the icon on hover: + +```html {.example} + GitHub Repo + +``` + ### Colors Icons inherit their color from the current text color. Thus, you can set the `color` property on the `` element or an ancestor to change the color. diff --git a/docs/docs/test/benchmark.njk b/docs/docs/test/benchmark.njk new file mode 100644 index 000000000..50183155d --- /dev/null +++ b/docs/docs/test/benchmark.njk @@ -0,0 +1,71 @@ +--- +title: CSS Properties Benchmark +unlisted: true +wide: true +--- + +{% set icons = { + check: '', + 'chevron-down': '', + 'chevron-left': '', + 'chevron-right': '', + circle: '', + 'eye-dropper': '', + 'grip-vertical': '', + indeterminate: '', + minus: '', + pause: '', + play: '', + star: '', + user: '', + xmark: '' +} %} + + + +{% set repetitions = 200 %} + +

Setting everything via attributes

+ +
+{% for icon, svg in icons %} + {% for i in range(repetitions) %} + + {% endfor %} +{% endfor %} +
+ +

Setting variant & family via CSS

+ +
+{% for icon, svg in icons %} + {% for i in range(repetitions) %} + + {% endfor %} +{% endfor %} +
+ +

Setting name via CSS

+ +
+{% for icon, svg in icons %} + + {% for i in range(repetitions) %} + + {% endfor %} + +{% endfor %} +
+ diff --git a/package-lock.json b/package-lock.json index 47f145152..f638e277c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "@shoelace-style/localize": "^3.2.1", "composed-offset-position": "^0.0.6", "lit": "^3.2.1", - "qr-creator": "^1.0.0" + "qr-creator": "^1.0.0", + "style-observer": "^0.0.7" }, "devDependencies": { "@11ty/eleventy": "3.0.0", @@ -13087,6 +13088,12 @@ "node": ">=0.8.0" } }, + "node_modules/style-observer": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/style-observer/-/style-observer-0.0.7.tgz", + "integrity": "sha512-t75H3CRy+vd5q3yqyrf/De4tkz33hPQTiCcfh0NTesI5G7kJnZ227LEYTwqjKTtaFOCJvqZcYFHpJlF8bsk3bQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index a5360ae35..070b6af40 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "@shoelace-style/localize": "^3.2.1", "composed-offset-position": "^0.0.6", "lit": "^3.2.1", - "qr-creator": "^1.0.0" + "qr-creator": "^1.0.0", + "style-observer": "^0.0.7" }, "devDependencies": { "@11ty/eleventy": "3.0.0", diff --git a/src/components/icon/icon.ts b/src/components/icon/icon.ts index b01a23c92..d55f1f769 100644 --- a/src/components/icon/icon.ts +++ b/src/components/icon/icon.ts @@ -48,21 +48,21 @@ export default class WaIcon extends WebAwesomeElement { @state() private svg: SVGElement | HTMLTemplateResult | null = null; /** The name of the icon to draw. Available names depend on the icon library being used. */ - @property({ reflect: true }) name?: string; + @property({ cssProperty: '--wa-icon-name' }) name?: string; /** * The family of icons to choose from. For Font Awesome Free (default), valid options include `classic` and `brands`. * For Font Awesome Pro subscribers, valid options include, `classic`, `sharp`, `duotone`, and `brands`. Custom icon * libraries may or may not use this property. */ - @property({ reflect: true }) family: string; + @property({ cssProperty: '--wa-icon-family' }) family: string; /** * The name of the icon's variant. For Font Awesome, valid options include `thin`, `light`, `regular`, and `solid` for * the `classic` and `sharp` families. Some variants require a Font Awesome Pro subscription. Custom icon libraries * may or may not use this property. */ - @property({ reflect: true }) variant: string; + @property({ cssProperty: '--wa-icon-variant' }) variant: string; /** Draws the icon in a fixed-width both. */ @property({ attribute: 'fixed-width', type: Boolean, reflect: true }) fixedWidth: false; @@ -80,14 +80,16 @@ export default class WaIcon extends WebAwesomeElement { @property() label = ''; /** The name of a registered custom icon library. */ - @property({ reflect: true }) library = 'default'; + @property({ cssProperty: '--wa-icon-library', default: 'default' }) library = 'default'; connectedCallback() { super.connectedCallback(); + watchIcon(this); } - firstUpdated() { + firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); this.initialRender = true; this.setIcon(); } diff --git a/src/internal/webawesome-element.ts b/src/internal/webawesome-element.ts index 01426e268..08d6640e9 100644 --- a/src/internal/webawesome-element.ts +++ b/src/internal/webawesome-element.ts @@ -1,7 +1,9 @@ import type { CSSResult, CSSResultGroup, PropertyDeclaration, PropertyValues } from 'lit'; import { LitElement, defaultConverter, isServer, unsafeCSS } from 'lit'; import { property } from 'lit/decorators.js'; +import { ElementStyleObserver } from 'style-observer'; import componentStyles from '../styles/shadow/component.css'; +import { getComputedStyle } from './computedStyle.js'; // Augment Lit's module declare module 'lit' { @@ -11,6 +13,11 @@ declare module 'lit' { */ default?: any; initial?: any; + + /** + * Indicates whether the property should reflect to a CSS custom property. + */ + cssProperty?: string; } } @@ -72,6 +79,99 @@ export default class WebAwesomeElement extends LitElement { internals: ElementInternals; + /** Metadata about CSS-settable props on this element */ + private cssProps: Record = {}; + private computedStyle: CSSStyleDeclaration | null = null; + private styleObserver: ElementStyleObserver | null = null; + + connectedCallback(): void { + super.connectedCallback(); + + // Set the initial computed styles + const Self = this.constructor as typeof WebAwesomeElement; + let cssProps = Object.keys(Self.cssProps); + + if (cssProps.length > 0) { + let properties: string[] = []; + + if (Object.keys(this.cssProps).length === 0) { + // First time connected, initialize + this.cssProps = Object.fromEntries( + cssProps.map(property => { + let setVia = this.getSetVia(property); + return [property, { setVia }]; + }), + ); + } + + for (let property in this.cssProps) { + let setVia = this.cssProps[property].setVia; + if (!setVia || setVia === 'css') { + // No attribute set, observe CSS property + properties.push(property); + } + } + + this.handleCSSPropertyChange(properties); + + this.styleObserver ??= new ElementStyleObserver(this, (records: object[]) => { + let cssProperties = new Set(records.map((record: { property: string }) => record.property)); + + // Map CSS properties to prop names + let properties = cssProps.filter(property => { + let cssProperty = Self.cssProps[property].cssProperty as string; + return cssProperties.has(cssProperty); + }); + + this.handleCSSPropertyChange(properties); + }); + this.styleObserver.unobserve(); + this.styleObserver.observe(properties.map(property => Self.cssProps[property].cssProperty as string)); + } + } + + /** + * Respond to CSS property changes for CSS properties reflecting props + * @param [properties] - Prop names. Defaults to all CSS-reflected props. + * @void + */ + handleCSSPropertyChange(properties?: PropertyKey | PropertyKey[]) { + const Self = this.constructor as typeof WebAwesomeElement; + + properties ??= Object.keys(Self.cssProps); + properties = Array.isArray(properties) ? properties : [properties]; + + if (properties.length === 0) { + return; + } + + this.computedStyle ??= getComputedStyle(this); + + for (let property of properties) { + let propOptions = Self.cssProps[property]; + let cssProperty = propOptions?.cssProperty; + let meta = this.cssProps[property]; + + if (!cssProperty || (meta.setVia && meta.setVia !== 'css')) { + continue; + } + + const value = this.computedStyle?.getPropertyValue(cssProperty); + // if (property === 'variant' && !value) debugger; + + if (value) { + meta.setVia = 'css'; + meta.updating = true; + // @ts-ignore + this[property] = value.trim(); + + this.updateComplete.then(() => { + meta.updating = false; + }); + } + } + } + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { if (!this.#hasRecordedInitialProperties) { (this.constructor as typeof WebAwesomeElement).elementProperties.forEach( @@ -115,6 +215,50 @@ export default class WebAwesomeElement extends LitElement { } } + /** + * Get how a prop was set + * @param property - The property to check + */ + private getSetVia(property: PropertyKey): 'css' | 'js' | 'attribute' | undefined { + let Self = this.constructor as typeof WebAwesomeElement; + let setVia; + let propOptions = Self.cssProps[property]; + let attribute = typeof propOptions.attribute === 'string' ? propOptions.attribute : (property as string); + + if (propOptions.attribute !== false && this.hasAttribute(attribute)) { + setVia = 'attribute'; + } else { + // @ts-ignore + let value = this[property as PropertyKey]; + if (value !== undefined && value !== propOptions.default) { + setVia = 'js'; + } + } + + return setVia as 'attribute' | 'js' | 'css' | undefined; + } + + protected updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + let Self = this.constructor as typeof WebAwesomeElement; + let cssProps = Object.keys(Self.cssProps); + + if (cssProps.length === 0) { + return; + } + + for (let [property] of changedProperties) { + let meta = this.cssProps[property]; + + if (meta && typeof property === 'string' && !(meta.setVia === 'css' && meta.updating)) { + // A prop is being set via JS or an attribute that was previously set via CSS + // and it's not because we're in the middle of an update + meta.setVia = this.getSetVia(property); + } + } + } + protected update(changedProperties: PropertyValues): void { try { super.update(changedProperties); @@ -230,6 +374,11 @@ export default class WebAwesomeElement extends LitElement { */ static rectProxy: undefined | string; + /** + * Props that can be set via CSS custom properties + */ + static cssProps: Record = {}; + static createProperty(name: PropertyKey, options?: PropertyDeclaration): void { if (options) { if (options.initial !== undefined && options.default === undefined) { @@ -256,8 +405,18 @@ export default class WebAwesomeElement extends LitElement { super.createProperty(name, options); - // Wrap the default accessor with logic to return the default value if the value is null if (options) { + if (options.cssProperty) { + // Add to the set of CSS-settable props + if (this.cssProps === WebAwesomeElement.cssProps) { + // Each class needs its own, otherwise they'd share the same object + this.cssProps = {}; + } + + this.cssProps[name] = options; + } + + // Wrap the default accessor with logic to return the default value if the value is null if (options.default !== undefined) { const descriptor = Object.getOwnPropertyDescriptor(this.prototype, name as string);