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);