diff --git a/.changeset/soft-icons-listen.md b/.changeset/soft-icons-listen.md new file mode 100644 index 0000000000..c1f71f54d9 --- /dev/null +++ b/.changeset/soft-icons-listen.md @@ -0,0 +1,39 @@ +--- +"@patternfly/elements": major +--- + +``: replaces `` with PatternFly v6 design specs. + +```html + +``` + +**Breaking Changes from v5** + +- Renamed tag from `` to `` +- CSS custom properties renamed from `--pf-v5-icon--size` to `--pf-v6-c-icon--*`, + matching the React component's custom properties. To size the icon, set + `size` or `--pf-v6-c-icon__content--FontSize`; to color it, set `status`, + `--pf-v6-c-icon__content--Color`, or set that property to `initial` and use + the CSS `color` property +- The default size changed: v5 added `size="sm"` (10px) to every instance; + v6 sizes to the body-default text size (0.875rem) with no attribute. Add + `size="sm"` to keep the v5 size + +**New features** + +- v6 sizes: `2xl`, `3xl`, body sizes (`body-sm`, `body-default`, `body-lg`), and + heading sizes (`heading-sm` through `heading-3xl`) +- `icon-size` attribute to size the icon independently of its container +- `status` attribute for `danger`, `warning`, `success`, `info`, and `custom` + status colors +- `inline` attribute to inherit the surrounding text's size and color +- `in-progress` attribute and `progress` slot, which swaps the icon for a + spinner (or a slotted element) without shifting surrounding content +- `mirror-rtl` attribute to mirror the icon in right-to-left languages +- `accessible-label` attribute, which exposes the icon as a labelled image; + without it the icon is decorative and hidden from screen readers + +**Note** + +`` remains available while the remaining v5 elements port to v6. diff --git a/docs/_snippets/pf-bar-html.md b/docs/_snippets/pf-bar-html.md index 5cfc26115f..c8427c8a8e 100644 --- a/docs/_snippets/pf-bar-html.md +++ b/docs/_snippets/pf-bar-html.md @@ -3,7 +3,7 @@ PatternFly.org - + Contribute on Github \ No newline at end of file diff --git a/docs/docs/develop/troubleshooting.md b/docs/docs/develop/troubleshooting.md index a9c0480b6e..2f56a1307c 100644 --- a/docs/docs/develop/troubleshooting.md +++ b/docs/docs/develop/troubleshooting.md @@ -19,7 +19,7 @@ tags: If you experience issues when using or developing with PatternFly Elements, please open an issue on [GitHub](https://github.com/patternfly/patternfly-elements/issues/new/choose). - + Open an issue diff --git a/docs/main.mjs b/docs/main.mjs index d56d42bf2d..002ad4b539 100644 --- a/docs/main.mjs +++ b/docs/main.mjs @@ -1,5 +1,5 @@ import '@rhds/elements/rh-footer/rh-footer-universal.js'; -import { PfV5Icon } from '@patternfly/elements/pf-v5-icon/pf-v5-icon.js'; +import { PfV6Icon } from '@patternfly/elements/pf-v6-icon/pf-v6-icon.js'; import '@patternfly/elements/pf-v5-accordion/pf-v5-accordion.js'; import '@patternfly/elements/pf-v5-alert/pf-v5-alert.js'; import '@patternfly/elements/pf-v5-back-to-top/pf-v5-back-to-top.js'; @@ -38,8 +38,7 @@ import '@patternfly/elements/pf-v6-timestamp/pf-v6-timestamp.js'; // if `/v2/` path load icons from static directory if (document.location.href.includes('/v2/')) { - PfV5Icon.getIconUrl = (set, icon) => { - const url = new URL(`/v2/components/icon/icons/${set}/${icon}.js`, import.meta.url); - return url; - }; + PfV6Icon.resolve = (set, icon) => + import(new URL(`/v2/components/icon/icons/${set}/${icon}.js`, import.meta.url).href) + .then(mod => mod.default.cloneNode(true)); } diff --git a/elements/pf-v6-banner/README.md b/elements/pf-v6-banner/README.md index 9a265f6dac..23303f2522 100644 --- a/elements/pf-v6-banner/README.md +++ b/elements/pf-v6-banner/README.md @@ -42,6 +42,6 @@ A full-width banner for communicating short, non-dismissible messages. | v5 API | v6 API | Notes | |---|---|---| | `variant` attribute (`default`, `info`, `warning`, `danger`, `success`) | `status` attribute (`success`, `warning`, `danger`, `info`, `custom`) | `variant` removed. Use `status` for semantic meaning or `color` for decorative colors. `default` variant maps to no attribute (gray). | -| `icon` attribute (shorthand) | Slot an icon in the default slot | `icon` attribute removed. Use `` (or `` when available) as slotted content. | +| `icon` attribute (shorthand) | Slot an icon in the default slot | `icon` attribute removed. Use `` as slotted content. | | `icon` slot (named) | Default slot | Icons are now part of the default slot content, not a separate named slot. | diff --git a/elements/pf-v6-banner/demo/status.html b/elements/pf-v6-banner/demo/status.html index 96b33aea56..351e221651 100644 --- a/elements/pf-v6-banner/demo/status.html +++ b/elements/pf-v6-banner/demo/status.html @@ -3,38 +3,38 @@ description: When a banner conveys status, use the status attribute. Include an icon and visually-hidden text for accessibility. --- - + Success alert: Success banner - + Warning alert: Warning banner - + Danger alert: Danger banner - + Info alert: Info banner - + Custom alert: Custom banner diff --git a/elements/pf-v6-icon/demo/custom-icon-sets.html b/elements/pf-v6-icon/demo/custom-icon-sets.html new file mode 100644 index 0000000000..a00a1dbabf --- /dev/null +++ b/elements/pf-v6-icon/demo/custom-icon-sets.html @@ -0,0 +1,35 @@ +--- +name: Custom icon sets +description: Register your own icon sets with a resolver function, so that any icon name in the set resolves to a module whose default export is a lit-renderable node. +--- +
+

Custom Icon Sets

+

Call PfV6Icon.addIconSet with a string (icon set name) and a function taking the + icon set name and the icon name, and returning a URL (object) to a javascript module whose + default export is a lit renderable.

+ + + + +
<pf-v6-icon set="rh" icon="boba-tea" size="lg" loading="lazy"></pf-v6-icon>
+<pf-v6-icon set="rh" icon="lifecycle" size="lg" loading="lazy"></pf-v6-icon>
+
import { PfV6Icon } from '@patternfly/elements/pf-v6-icon/pf-v6-icon.js';
+PfV6Icon.addIconSet('rh', async (set, icon) =>
+  import(`./icons/${set}/${icon}.js`)
+    .then(mod => mod.default));
+
+ + + + diff --git a/elements/pf-v6-icon/demo/custom-in-progress-icon.html b/elements/pf-v6-icon/demo/custom-in-progress-icon.html new file mode 100644 index 0000000000..eab962131b --- /dev/null +++ b/elements/pf-v6-icon/demo/custom-in-progress-icon.html @@ -0,0 +1,36 @@ +--- +name: Custom in progress icon +description: Slot an element into the progress slot to replace the default spinner while in progress is set. +--- +
+ + + + +
+ + + + diff --git a/elements/pf-v6-icon/demo/heading-sizes.html b/elements/pf-v6-icon/demo/heading-sizes.html new file mode 100644 index 0000000000..0ebda5a239 --- /dev/null +++ b/elements/pf-v6-icon/demo/heading-sizes.html @@ -0,0 +1,25 @@ +--- +name: Heading sizes +description: These size options are meant to make icons match the size of heading text. +--- +
+ + + + + + +
+ + + + diff --git a/elements/pf-v6-icon/demo/icons/rh/boba-tea.js b/elements/pf-v6-icon/demo/icons/rh/boba-tea.js new file mode 100644 index 0000000000..cf031dbebb --- /dev/null +++ b/elements/pf-v6-icon/demo/icons/rh/boba-tea.js @@ -0,0 +1,5 @@ +const t = document.createElement('template');t.innerHTML=` + + + +`;export default t.content.cloneNode(true); diff --git a/elements/pf-v6-icon/demo/icons/rh/lifecycle.js b/elements/pf-v6-icon/demo/icons/rh/lifecycle.js new file mode 100644 index 0000000000..6debf53f1d --- /dev/null +++ b/elements/pf-v6-icon/demo/icons/rh/lifecycle.js @@ -0,0 +1,3 @@ +const t = document.createElement('template');t.innerHTML=` + +`;export default t.content.cloneNode(true); diff --git a/elements/pf-v6-icon/demo/in-progress.html b/elements/pf-v6-icon/demo/in-progress.html new file mode 100644 index 0000000000..daa634a96e --- /dev/null +++ b/elements/pf-v6-icon/demo/in-progress.html @@ -0,0 +1,29 @@ +--- +name: In progress +description: Setting the in-progress attribute swaps the icon for a progress indicator. By default the progress indicator is a spinner, sized to the icon. +--- +
+ + +
+ + + + diff --git a/elements/pf-v6-icon/demo/index.html b/elements/pf-v6-icon/demo/index.html new file mode 100644 index 0000000000..0c099e5220 --- /dev/null +++ b/elements/pf-v6-icon/demo/index.html @@ -0,0 +1,20 @@ +--- +name: Basic +description: A basic icon displays an icon from the default icon set. +--- +
+ + + + +
+ + + + diff --git a/elements/pf-v6-icon/demo/inline.html b/elements/pf-v6-icon/demo/inline.html new file mode 100644 index 0000000000..db13718bf0 --- /dev/null +++ b/elements/pf-v6-icon/demo/inline.html @@ -0,0 +1,36 @@ +--- +name: Inline +description: Inline icons inherit the text font size and color of their surrounding text. +--- +
+

Heading

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed hendrerit nisi in cursus maximus.

+

Second level

+

+ + Curabitur accumsan turpis pharetra + augue tincidunt + blandit. Quisque condimentum maximus mi, sit amet commodo arcu rutrum id. Proin pretium urna + vel cursus venenatis. Suspendisse potenti. +

+

+ Sometimes you need small text +

+

+ Inline with size specified: + small, + medium, + large, + extra large +

+
+ + + + diff --git a/elements/pf-v6-icon/demo/sets.html b/elements/pf-v6-icon/demo/sets.html new file mode 100644 index 0000000000..f43ee5db52 --- /dev/null +++ b/elements/pf-v6-icon/demo/sets.html @@ -0,0 +1,116 @@ +--- +name: Icon sets +description: Browse the built-in icon sets. Search for an icon name, then click an icon to copy its HTML. +--- +
+

Default Icon Sets

+

Use the text field to search for an icon name. Click an icon to copy its HTML.

+

PatternFly Elements ships four built-in icon sets. The default is Font Awesome Solid.

+ + + + +
+ + + + diff --git a/elements/pf-v6-icon/demo/sizing-an-icon-within-the-icon-container.html b/elements/pf-v6-icon/demo/sizing-an-icon-within-the-icon-container.html new file mode 100644 index 0000000000..2d3612ba0a --- /dev/null +++ b/elements/pf-v6-icon/demo/sizing-an-icon-within-the-icon-container.html @@ -0,0 +1,23 @@ +--- +name: Sizing an icon within the icon container +description: Providing both size and icon-size allows the icon container to maintain a consistent size, even if the icon changes in size. +--- +
+ + + + +
+ + + + diff --git a/elements/pf-v6-icon/demo/standalone-icon-sizes.html b/elements/pf-v6-icon/demo/standalone-icon-sizes.html new file mode 100644 index 0000000000..02dec64df6 --- /dev/null +++ b/elements/pf-v6-icon/demo/standalone-icon-sizes.html @@ -0,0 +1,25 @@ +--- +name: Standalone icon sizes +description: These are the standard options for sizing icons. +--- +
+ + + + + + +
+ + + + diff --git a/elements/pf-v6-icon/demo/status-colors.html b/elements/pf-v6-icon/demo/status-colors.html new file mode 100644 index 0000000000..f6b1daf2bc --- /dev/null +++ b/elements/pf-v6-icon/demo/status-colors.html @@ -0,0 +1,24 @@ +--- +name: Status colors +description: The status attribute colors the icon. Keep in mind that these icons are displayed out of a typical context, and that you should not rely on color alone to convey status to users. +--- +
+ + + + + +
+ + + + diff --git a/elements/pf-v6-icon/pf-v6-icon.css b/elements/pf-v6-icon/pf-v6-icon.css new file mode 100644 index 0000000000..7eb61b522d --- /dev/null +++ b/elements/pf-v6-icon/pf-v6-icon.css @@ -0,0 +1,354 @@ +[hidden] { + display: none !important; +} + +/* Component-level token defaults, mirroring the React component's pf-root block. + Light DOM rules targeting the host override these; setting + `--pf-v6-c-icon__content--Color` to `initial` makes the icon inherit the + surrounding text color. */ +:host { + /** Width of the icon container. Defaults to the body-default icon size. */ + --pf-v6-c-icon--Width: var(--pf-t--global--icon--size--font--body--default, 0.875rem); + /** Height of the icon container. Defaults to the body-default icon size. */ + --pf-v6-c-icon--Height: var(--pf-t--global--icon--size--font--body--default, 0.875rem); + /** Font size of the icon, which sizes the icon's SVG. Defaults to the body-default icon size. */ + --pf-v6-c-icon__content--FontSize: var(--pf-t--global--icon--size--font--body--default, 0.875rem); + --pf-v6-c-icon__content--Color: + /** Color of the icon */ + var(--pf-t--global--icon--color--regular, + light-dark(var(--pf-t--color--gray--90, #1f1f1f), var(--pf-t--color--gray--10, #f2f2f2))); + /** Vertical alignment of the icon SVG within text. */ + --pf-v6-c-icon__content--svg--VerticalAlign: -0.125em; + /** Opacity of the icon content; `0` while in progress. */ + --pf-v6-c-icon__content--Opacity: 1; + /** Opacity of the progress indicator; `1` while in progress. */ + --pf-v6-c-icon__progress--Opacity: 0; + + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: + /** Width of the icon container */ + var(--pf-v6-c-icon--Width); + block-size: + /** Height of the icon container */ + var(--pf-v6-c-icon--Height); +} + +/* Inline icons inherit the surrounding text's size and color. Written before + the size modifiers so that an explicit `size` wins, as in React. */ +:host([inline]) { + line-height: 1; + + /** Width of the icon container when inline. */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-inline--Width, 1em); + /** Height of the icon container when inline. */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-inline--Height, 1em); + /** Font size of the icon when inline. */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-inline__content--FontSize, 1em); + /** Color of the icon when inline: `initial` inherits the CSS `color` property. */ + --pf-v6-c-icon__content--Color: initial; +} + +#content, +#progress { + font-size: + /** Font size of the icon, which sizes the icon's SVG */ + var(--pf-v6-c-icon__content--FontSize); +} + +#content { + color: + /** Color of the icon */ + var(--pf-v6-c-icon__content--Color, inherit); + opacity: + /** Opacity of the icon content; `0` while in progress */ + var(--pf-v6-c-icon__content--Opacity, 1); +} + +svg { + width: 1em; + height: 1em; + fill: currentcolor; + vertical-align: + /** Vertical alignment of the icon SVG within text */ + var(--pf-v6-c-icon__content--svg--VerticalAlign, -0.125em); +} + +/* Status colors */ +:host([status="danger"]) { + /** Icon color when status is danger. Defaults to the `--pf-t--global--icon--color--status--danger--default` token. */ + --pf-v6-c-icon__content--Color: var(--pf-v6-c-icon__content--m-danger--Color, + var(--pf-t--global--icon--color--status--danger--default, + light-dark(var(--pf-t--color--red-orange--60, #b1380b), var(--pf-t--color--red-orange--50, #f0561d)))); +} + +:host([status="warning"]) { + /** Icon color when status is warning. Defaults to the `--pf-t--global--icon--color--status--warning--default` token. */ + --pf-v6-c-icon__content--Color: var(--pf-v6-c-icon__content--m-warning--Color, + var(--pf-t--global--icon--color--status--warning--default, + light-dark(var(--pf-t--color--yellow--40, #dca614), var(--pf-t--color--yellow--30, #ffcc17)))); +} + +:host([status="success"]) { + /** Icon color when status is success. Defaults to the `--pf-t--global--icon--color--status--success--default` token. */ + --pf-v6-c-icon__content--Color: var(--pf-v6-c-icon__content--m-success--Color, + var(--pf-t--global--icon--color--status--success--default, + light-dark(var(--pf-t--color--green--60, #3d7317), var(--pf-t--color--green--40, #87bb62)))); +} + +:host([status="info"]) { + /** Icon color when status is info. Defaults to the `--pf-t--global--icon--color--status--info--default` token. */ + --pf-v6-c-icon__content--Color: var(--pf-v6-c-icon__content--m-info--Color, + var(--pf-t--global--icon--color--status--info--default, + light-dark(var(--pf-t--color--purple--50, #5e40be), var(--pf-t--color--purple--30, #b6a6e9)))); +} + +:host([status="custom"]) { + /** Icon color when status is custom. Defaults to the `--pf-t--global--icon--color--status--custom--default` token. */ + --pf-v6-c-icon__content--Color: var(--pf-v6-c-icon__content--m-custom--Color, + var(--pf-t--global--icon--color--status--custom--default, + light-dark(var(--pf-t--color--teal--60, #147878), var(--pf-t--color--teal--40, #63bdbd)))); +} + +/* Progress indicator. Absolutely positioned and centered over the container, + so that the container keeps its size while the icon is swapped out. */ +#progress { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: + /** Opacity of the progress indicator; `1` while in progress */ + var(--pf-v6-c-icon__progress--Opacity, 0); +} + +:host([in-progress]) { + /** Opacity of the icon content while in progress */ + --pf-v6-c-icon__content--Opacity: 0; + /** Opacity of the progress indicator while in progress */ + --pf-v6-c-icon__progress--Opacity: 1; +} + +/* Mirroring for right-to-left languages */ +:host([mirror-rtl]:dir(rtl)) svg { + scale: -1 1; +} + +/* Standalone sizes: fixed lengths for both container and icon */ +:host([size="sm"]) { + /** Width of the icon container when size is sm. Defaults to the `--pf-t--global--icon--size--sm` token. */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-sm--Width, var(--pf-t--global--icon--size--sm, 0.75rem)); + /** Height of the icon container when size is sm. Defaults to the `--pf-t--global--icon--size--sm` token. */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-sm--Height, var(--pf-t--global--icon--size--sm, 0.75rem)); + /** Icon font size when size is sm. Defaults to the `--pf-t--global--icon--size--sm` token. */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-sm__content--FontSize, var(--pf-t--global--icon--size--sm, 0.75rem)); +} + +:host([size="md"]) { + /** Width of the icon container when size is md. Defaults to the `--pf-t--global--icon--size--md` token. */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-md--Width, var(--pf-t--global--icon--size--md, 0.875rem)); + /** Height of the icon container when size is md. Defaults to the `--pf-t--global--icon--size--md` token. */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-md--Height, var(--pf-t--global--icon--size--md, 0.875rem)); + /** Icon font size when size is md. Defaults to the `--pf-t--global--icon--size--md` token. */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-md__content--FontSize, var(--pf-t--global--icon--size--md, 0.875rem)); +} + +:host([size="lg"]) { + /** Width of the icon container when size is lg. Defaults to the `--pf-t--global--icon--size--lg` token. */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-lg--Width, var(--pf-t--global--icon--size--lg, 1rem)); + /** Height of the icon container when size is lg. Defaults to the `--pf-t--global--icon--size--lg` token. */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-lg--Height, var(--pf-t--global--icon--size--lg, 1rem)); + /** Icon font size when size is lg. Defaults to the `--pf-t--global--icon--size--lg` token. */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-lg__content--FontSize, var(--pf-t--global--icon--size--lg, 1rem)); +} + +:host([size="xl"]) { + /** Width of the icon container when size is xl. Defaults to the `--pf-t--global--icon--size--xl` token. */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-xl--Width, var(--pf-t--global--icon--size--xl, 1.5rem)); + /** Height of the icon container when size is xl. Defaults to the `--pf-t--global--icon--size--xl` token. */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-xl--Height, var(--pf-t--global--icon--size--xl, 1.5rem)); + /** Icon font size when size is xl. Defaults to the `--pf-t--global--icon--size--xl` token. */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-xl__content--FontSize, var(--pf-t--global--icon--size--xl, 1.5rem)); +} + +:host([size="2xl"]) { + /** Width of the icon container when size is 2xl. Defaults to the `--pf-t--global--icon--size--2xl` token. */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-2xl--Width, var(--pf-t--global--icon--size--2xl, 3.5rem)); + /** Height of the icon container when size is 2xl. Defaults to the `--pf-t--global--icon--size--2xl` token. */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-2xl--Height, var(--pf-t--global--icon--size--2xl, 3.5rem)); + /** Icon font size when size is 2xl. Defaults to the `--pf-t--global--icon--size--2xl` token. */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-2xl__content--FontSize, var(--pf-t--global--icon--size--2xl, 3.5rem)); +} + +:host([size="3xl"]) { + /** Width of the icon container when size is 3xl. Defaults to the `--pf-t--global--icon--size--3xl` token. */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-3xl--Width, var(--pf-t--global--icon--size--3xl, 6rem)); + /** Height of the icon container when size is 3xl. Defaults to the `--pf-t--global--icon--size--3xl` token. */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-3xl--Height, var(--pf-t--global--icon--size--3xl, 6rem)); + /** Icon font size when size is 3xl. Defaults to the `--pf-t--global--icon--size--3xl` token. */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-3xl__content--FontSize, var(--pf-t--global--icon--size--3xl, 6rem)); +} + +/* Body sizes: track body text sizes */ +:host([size="body-sm"]) { + /** Container width when size is body-sm. Defaults to the `--pf-t--global--icon--size--font--body--sm` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-body-sm--Width, var(--pf-t--global--icon--size--font--body--sm, 0.75rem)); + /** Container height when size is body-sm. Defaults to the `--pf-t--global--icon--size--font--body--sm` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-body-sm--Height, var(--pf-t--global--icon--size--font--body--sm, 0.75rem)); + /** Icon font size when size is body-sm. Defaults to the `--pf-t--global--icon--size--font--body--sm` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-body-sm__content--FontSize, var(--pf-t--global--icon--size--font--body--sm, 0.75rem)); +} + +:host([size="body-default"]) { + /** Container width when size is body-default. Defaults to the `--pf-t--global--icon--size--font--body--default` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-body-default--Width, var(--pf-t--global--icon--size--font--body--default, 0.875rem)); + /** Container height when size is body-default. Defaults to the `--pf-t--global--icon--size--font--body--default` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-body-default--Height, var(--pf-t--global--icon--size--font--body--default, 0.875rem)); + /** Icon font size when size is body-default. Defaults to the `--pf-t--global--icon--size--font--body--default` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-body-default__content--FontSize, var(--pf-t--global--icon--size--font--body--default, 0.875rem)); +} + +:host([size="body-lg"]) { + /** Container width when size is body-lg. Defaults to the `--pf-t--global--icon--size--font--body--lg` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-body-lg--Width, var(--pf-t--global--icon--size--font--body--lg, 1rem)); + /** Container height when size is body-lg. Defaults to the `--pf-t--global--icon--size--font--body--lg` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-body-lg--Height, var(--pf-t--global--icon--size--font--body--lg, 1rem)); + /** Icon font size when size is body-lg. Defaults to the `--pf-t--global--icon--size--font--body--lg` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-body-lg__content--FontSize, var(--pf-t--global--icon--size--font--body--lg, 1rem)); +} + +/* Heading sizes: track heading text sizes */ +:host([size="heading-sm"]) { + /** Container width when size is heading-sm. Defaults to the `--pf-t--global--icon--size--font--heading--h6` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-heading-sm--Width, var(--pf-t--global--icon--size--font--heading--h6, 1rem)); + /** Container height when size is heading-sm. Defaults to the `--pf-t--global--icon--size--font--heading--h6` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-heading-sm--Height, var(--pf-t--global--icon--size--font--heading--h6, 1rem)); + /** Icon font size when size is heading-sm. Defaults to the `--pf-t--global--icon--size--font--heading--h6` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-sm__content--FontSize, var(--pf-t--global--icon--size--font--heading--h6, 1rem)); +} + +:host([size="heading-md"]) { + /** Container width when size is heading-md. Defaults to the `--pf-t--global--icon--size--font--heading--h5` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-heading-md--Width, var(--pf-t--global--icon--size--font--heading--h5, 1rem)); + /** Container height when size is heading-md. Defaults to the `--pf-t--global--icon--size--font--heading--h5` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-heading-md--Height, var(--pf-t--global--icon--size--font--heading--h5, 1rem)); + /** Icon font size when size is heading-md. Defaults to the `--pf-t--global--icon--size--font--heading--h5` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-md__content--FontSize, var(--pf-t--global--icon--size--font--heading--h5, 1rem)); +} + +:host([size="heading-lg"]) { + /** Container width when size is heading-lg. Defaults to the `--pf-t--global--icon--size--font--heading--h4` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-heading-lg--Width, var(--pf-t--global--icon--size--font--heading--h4, 1rem)); + /** Container height when size is heading-lg. Defaults to the `--pf-t--global--icon--size--font--heading--h4` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-heading-lg--Height, var(--pf-t--global--icon--size--font--heading--h4, 1rem)); + /** Icon font size when size is heading-lg. Defaults to the `--pf-t--global--icon--size--font--heading--h4` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-lg__content--FontSize, var(--pf-t--global--icon--size--font--heading--h4, 1rem)); +} + +:host([size="heading-xl"]) { + /** Container width when size is heading-xl. Defaults to the `--pf-t--global--icon--size--font--heading--h3` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-heading-xl--Width, var(--pf-t--global--icon--size--font--heading--h3, 1.125rem)); + /** Container height when size is heading-xl. Defaults to the `--pf-t--global--icon--size--font--heading--h3` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-heading-xl--Height, var(--pf-t--global--icon--size--font--heading--h3, 1.125rem)); + /** Icon font size when size is heading-xl. Defaults to the `--pf-t--global--icon--size--font--heading--h3` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-xl__content--FontSize, var(--pf-t--global--icon--size--font--heading--h3, 1.125rem)); +} + +:host([size="heading-2xl"]) { + /** Container width when size is heading-2xl. Defaults to the `--pf-t--global--icon--size--font--heading--h2` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-heading-2xl--Width, var(--pf-t--global--icon--size--font--heading--h2, 1.25rem)); + /** Container height when size is heading-2xl. Defaults to the `--pf-t--global--icon--size--font--heading--h2` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-heading-2xl--Height, var(--pf-t--global--icon--size--font--heading--h2, 1.25rem)); + /** Icon font size when size is heading-2xl. Defaults to the `--pf-t--global--icon--size--font--heading--h2` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-2xl__content--FontSize, var(--pf-t--global--icon--size--font--heading--h2, 1.25rem)); +} + +:host([size="heading-3xl"]) { + /** Container width when size is heading-3xl. Defaults to the `--pf-t--global--icon--size--font--heading--h1` token */ + --pf-v6-c-icon--Width: var(--pf-v6-c-icon--m-heading-3xl--Width, var(--pf-t--global--icon--size--font--heading--h1, 1.5rem)); + /** Container height when size is heading-3xl. Defaults to the `--pf-t--global--icon--size--font--heading--h1` token */ + --pf-v6-c-icon--Height: var(--pf-v6-c-icon--m-heading-3xl--Height, var(--pf-t--global--icon--size--font--heading--h1, 1.5rem)); + /** Icon font size when size is heading-3xl. Defaults to the `--pf-t--global--icon--size--font--heading--h1` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-3xl__content--FontSize, var(--pf-t--global--icon--size--font--heading--h1, 1.5rem)); +} + +/* Icon-only sizes: size the icon within the container without resizing the + container. Written after the `size` modifiers so that `icon-size` wins + when both are set. */ +:host([icon-size="sm"]) { + /** Icon font size when icon-size is sm. Defaults to the `--pf-t--global--icon--size--sm` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-sm__content--FontSize, var(--pf-t--global--icon--size--sm, 0.75rem)); +} + +:host([icon-size="md"]) { + /** Icon font size when icon-size is md. Defaults to the `--pf-t--global--icon--size--md` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-md__content--FontSize, var(--pf-t--global--icon--size--md, 0.875rem)); +} + +:host([icon-size="lg"]) { + /** Icon font size when icon-size is lg. Defaults to the `--pf-t--global--icon--size--lg` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-lg__content--FontSize, var(--pf-t--global--icon--size--lg, 1rem)); +} + +:host([icon-size="xl"]) { + /** Icon font size when icon-size is xl. Defaults to the `--pf-t--global--icon--size--xl` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-xl__content--FontSize, var(--pf-t--global--icon--size--xl, 1.5rem)); +} + +:host([icon-size="2xl"]) { + /** Icon font size when icon-size is 2xl. Defaults to the `--pf-t--global--icon--size--2xl` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-2xl__content--FontSize, var(--pf-t--global--icon--size--2xl, 3.5rem)); +} + +:host([icon-size="3xl"]) { + /** Icon font size when icon-size is 3xl. Defaults to the `--pf-t--global--icon--size--3xl` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-3xl__content--FontSize, var(--pf-t--global--icon--size--3xl, 6rem)); +} + +:host([icon-size="body-sm"]) { + /** Icon font size when icon-size is body-sm. Defaults to the `--pf-t--global--icon--size--font--body--sm` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-body-sm__content--FontSize, var(--pf-t--global--icon--size--font--body--sm, 0.75rem)); +} + +:host([icon-size="body-default"]) { + /** Icon font size when icon-size is body-default. Defaults to the `--pf-t--global--icon--size--font--body--default` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-body-default__content--FontSize, var(--pf-t--global--icon--size--font--body--default, 0.875rem)); +} + +:host([icon-size="body-lg"]) { + /** Icon font size when icon-size is body-lg. Defaults to the `--pf-t--global--icon--size--font--body--lg` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-body-lg__content--FontSize, var(--pf-t--global--icon--size--font--body--lg, 1rem)); +} + +:host([icon-size="heading-sm"]) { + /** Icon font size when icon-size is heading-sm. Defaults to the `--pf-t--global--icon--size--font--heading--h6` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-sm__content--FontSize, var(--pf-t--global--icon--size--font--heading--h6, 1rem)); +} + +:host([icon-size="heading-md"]) { + /** Icon font size when icon-size is heading-md. Defaults to the `--pf-t--global--icon--size--font--heading--h5` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-md__content--FontSize, var(--pf-t--global--icon--size--font--heading--h5, 1rem)); +} + +:host([icon-size="heading-lg"]) { + /** Icon font size when icon-size is heading-lg. Defaults to the `--pf-t--global--icon--size--font--heading--h4` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-lg__content--FontSize, var(--pf-t--global--icon--size--font--heading--h4, 1rem)); +} + +:host([icon-size="heading-xl"]) { + /** Icon font size when icon-size is heading-xl. Defaults to the `--pf-t--global--icon--size--font--heading--h3` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-xl__content--FontSize, var(--pf-t--global--icon--size--font--heading--h3, 1.125rem)); +} + +:host([icon-size="heading-2xl"]) { + /** Icon font size when icon-size is heading-2xl. Defaults to the `--pf-t--global--icon--size--font--heading--h2` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-2xl__content--FontSize, var(--pf-t--global--icon--size--font--heading--h2, 1.25rem)); +} + +:host([icon-size="heading-3xl"]) { + /** Icon font size when icon-size is heading-3xl. Defaults to the `--pf-t--global--icon--size--font--heading--h1` token */ + --pf-v6-c-icon__content--FontSize: var(--pf-v6-c-icon--m-heading-3xl__content--FontSize, var(--pf-t--global--icon--size--font--heading--h1, 1.5rem)); +} diff --git a/elements/pf-v6-icon/pf-v6-icon.ts b/elements/pf-v6-icon/pf-v6-icon.ts new file mode 100644 index 0000000000..dbf2713ad7 --- /dev/null +++ b/elements/pf-v6-icon/pf-v6-icon.ts @@ -0,0 +1,403 @@ +import { LitElement, html, isServer, type PropertyValues, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { state } from 'lit/decorators/state.js'; + +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; + +import '@patternfly/elements/pf-v6-spinner/pf-v6-spinner.js'; + +import style from './pf-v6-icon.css'; + +/** + * Sizes for the icon container and icon. + * Standalone sizes (`sm` through `3xl`) are fixed lengths. + * Body and heading sizes track the corresponding text styles. + */ +export type IconSize = + | 'sm' + | 'md' + | 'lg' + | 'xl' + | '2xl' + | '3xl' + | 'body-sm' + | 'body-default' + | 'body-lg' + | 'heading-sm' + | 'heading-md' + | 'heading-lg' + | 'heading-xl' + | 'heading-2xl' + | 'heading-3xl'; + +/** Status colors for the icon */ +export type IconStatus = 'custom' | 'danger' | 'info' | 'success' | 'warning'; + +type Renderable = unknown; + +export type IconResolverFunction = (set: string, icon: string) => + Renderable | Promise; + +/** + * requestIdleCallback when available, requestAnimationFrame when not + * @param f callback + */ +const ric: typeof globalThis.requestIdleCallback = + globalThis.requestIdleCallback + ?? globalThis.requestAnimationFrame + ?? (async (f: () => void) => Promise.resolve().then(f)); + +/** + * Fired when an icon fails to load, for example when an icon module cannot + * be imported or resolved. Provides the icon and set names in `message`, + * and the underlying error in `originalError`, so that hosts can present a + * fallback or log the failure. + */ +export class IconResolveError extends ErrorEvent { + constructor( + set: string, + icon: string, + /** The original error when importing the icon module */ + public originalError: Error + ) { + super('error', { message: `Could not load icon "${icon}" from set "${set}".` }); + } +} + +/** + * An **icon** is a container that allows icons of varying dimensions to + * replace each other without shifting surrounding content. Icons resolve + * by name from an icon set (`fas` by default). + * + * Icons are decorative by default (ARIA `aria-hidden`): screen readers + * ignore them. Authors MUST set `accessible-label` for meaningful icons, + * and SHOULD use a button for interactive iconography. + * + * The element is not interactive and cannot receive focus; wrap it in a + * button or link for interactive iconography. + * + * @summary Displays an icon + * + * @slot - Fallback content while the icon is unavailable or fails to load. + * Screen readers ignore it when the icon is decorative + * @slot progress - Element shown while `in-progress` is set. Defaults to + * ``, which announces "Loading..." to + * screen readers. Slotted elements SHOULD provide their + * own accessible name + * + * @fires load - Fired when an icon is loaded and rendered. Does not bubble + * @fires error - Fired when an icon fails to load. Does not bubble. + * `IconResolveError` extends `ErrorEvent`; its `message` + * names the icon and set, and its `originalError` property + * holds the underlying error + * + * @csspart fallback - Container for fallback (i.e. slotted) content + * + * @cssprop {} --pf-v6-c-icon--Width - Width of the icon container + * @cssprop {} --pf-v6-c-icon--Height - Height of the icon container + * @cssprop {} --pf-v6-c-icon--m-sm--Width - Container width when size is sm + * @cssprop {} --pf-v6-c-icon--m-sm--Height - Container height when size is sm + * @cssprop {} --pf-v6-c-icon--m-md--Width - Container width when size is md + * @cssprop {} --pf-v6-c-icon--m-md--Height - Container height when size is md + * @cssprop {} --pf-v6-c-icon--m-lg--Width - Container width when size is lg + * @cssprop {} --pf-v6-c-icon--m-lg--Height - Container height when size is lg + * @cssprop {} --pf-v6-c-icon--m-xl--Width - Container width when size is xl + * @cssprop {} --pf-v6-c-icon--m-xl--Height - Container height when size is xl + * @cssprop {} --pf-v6-c-icon--m-2xl--Width - Container width when size is 2xl + * @cssprop {} --pf-v6-c-icon--m-2xl--Height - Container height when size is 2xl + * @cssprop {} --pf-v6-c-icon--m-3xl--Width - Container width when size is 3xl + * @cssprop {} --pf-v6-c-icon--m-3xl--Height - Container height when size is 3xl + * @cssprop {} --pf-v6-c-icon--m-body-sm--Width - Container width when size is body-sm + * @cssprop {} --pf-v6-c-icon--m-body-sm--Height - Container height when size is body-sm + * @cssprop {} --pf-v6-c-icon--m-body-default--Width - Container width when size is body-default + * @cssprop {} --pf-v6-c-icon--m-body-default--Height - Container height when size is body-default + * @cssprop {} --pf-v6-c-icon--m-body-lg--Width - Container width when size is body-lg + * @cssprop {} --pf-v6-c-icon--m-body-lg--Height - Container height when size is body-lg + * @cssprop {} --pf-v6-c-icon--m-heading-sm--Width - Container width when size is heading-sm + * @cssprop {} --pf-v6-c-icon--m-heading-sm--Height - Container height when size is heading-sm + * @cssprop {} --pf-v6-c-icon--m-heading-md--Width - Container width when size is heading-md + * @cssprop {} --pf-v6-c-icon--m-heading-md--Height - Container height when size is heading-md + * @cssprop {} --pf-v6-c-icon--m-heading-lg--Width - Container width when size is heading-lg + * @cssprop {} --pf-v6-c-icon--m-heading-lg--Height - Container height when size is heading-lg + * @cssprop {} --pf-v6-c-icon--m-heading-xl--Width - Container width when size is heading-xl + * @cssprop {} --pf-v6-c-icon--m-heading-xl--Height - Container height when size is heading-xl + * @cssprop {} --pf-v6-c-icon--m-heading-2xl--Width - Container width when size is heading-2xl + * @cssprop {} --pf-v6-c-icon--m-heading-2xl--Height - Container height when size is heading-2xl + * @cssprop {} --pf-v6-c-icon--m-heading-3xl--Width - Container width when size is heading-3xl + * @cssprop {} --pf-v6-c-icon--m-heading-3xl--Height - Container height when size is heading-3xl + * @cssprop {} --pf-v6-c-icon__content--FontSize - Font size of the icon + * @cssprop {} --pf-v6-c-icon--m-sm__content--FontSize - Icon font size when size is sm + * @cssprop {} --pf-v6-c-icon--m-md__content--FontSize - Icon font size when size is md + * @cssprop {} --pf-v6-c-icon--m-lg__content--FontSize - Icon font size when size is lg + * @cssprop {} --pf-v6-c-icon--m-xl__content--FontSize - Icon font size when size is xl + * @cssprop {} --pf-v6-c-icon--m-2xl__content--FontSize - Icon font size when size is 2xl + * @cssprop {} --pf-v6-c-icon--m-3xl__content--FontSize - Icon font size when size is 3xl + * @cssprop {} --pf-v6-c-icon--m-body-sm__content--FontSize - Icon font size when size is body-sm + * @cssprop {} --pf-v6-c-icon--m-body-default__content--FontSize - Icon font size when size is body-default + * @cssprop {} --pf-v6-c-icon--m-body-lg__content--FontSize - Icon font size when size is body-lg + * @cssprop {} --pf-v6-c-icon--m-heading-sm__content--FontSize - Icon font size when size is heading-sm + * @cssprop {} --pf-v6-c-icon--m-heading-md__content--FontSize - Icon font size when size is heading-md + * @cssprop {} --pf-v6-c-icon--m-heading-lg__content--FontSize - Icon font size when size is heading-lg + * @cssprop {} --pf-v6-c-icon--m-heading-xl__content--FontSize - Icon font size when size is heading-xl + * @cssprop {} --pf-v6-c-icon--m-heading-2xl__content--FontSize - Icon font size when size is heading-2xl + * @cssprop {} --pf-v6-c-icon--m-heading-3xl__content--FontSize - Icon font size when size is heading-3xl + * @cssprop {} --pf-v6-c-icon--m-inline--Width - Container width when inline + * @cssprop {} --pf-v6-c-icon--m-inline--Height - Container height when inline + * @cssprop {} --pf-v6-c-icon--m-inline__content--FontSize - Icon font size when inline + * @cssprop {} --pf-v6-c-icon__content--Color - Color of the icon + * @cssprop {} --pf-v6-c-icon__content--m-danger--Color - Icon color when status is danger + * @cssprop {} --pf-v6-c-icon__content--m-warning--Color - Icon color when status is warning + * @cssprop {} --pf-v6-c-icon__content--m-success--Color - Icon color when status is success + * @cssprop {} --pf-v6-c-icon__content--m-info--Color - Icon color when status is info + * @cssprop {} --pf-v6-c-icon__content--m-custom--Color - Icon color when status is custom + * @cssprop {} --pf-v6-c-icon__content--svg--VerticalAlign - Vertical alignment of the icon + * @cssprop {} --pf-v6-c-icon__content--Opacity - Opacity of the icon content + * @cssprop {} --pf-v6-c-icon__progress--Opacity - Opacity of the progress indicator + */ +@customElement('pf-v6-icon') +export class PfV6Icon extends LitElement { + static readonly styles: CSSStyleSheet[] = [style]; + + private static io: IntersectionObserver | null = null; + + private static onIntersect: IntersectionObserverCallback = records => + records.forEach(({ isIntersecting, target }) => { + const icon = target as PfV6Icon; + icon.#intersecting = isIntersecting; + ric(() => { + if (icon.#intersecting) { + icon.#load(); + } + }); + }); + + private static defaultResolve: IconResolverFunction = (set: string, icon: string): Renderable => + import(`@patternfly/icons/${set}/${icon}.js`) + .then(mod => mod.default.cloneNode(true)); + + /** + * Gets a renderable icon. Override this to customize how icons are resolved. + * @param set - The name of the icon set + * @param icon - The name of the icon + * @returns The icon content, a node or anything else which lit-html can render + * @example resolving an icon node from an icon module + * ```js + * PfV6Icon.resolve = (set, icon) => + * import(`/assets/icons/${set}/${icon}.js`) + * .then(mod => mod.default.cloneNode(true)); + * ``` + * @example resolving a named export from an icon collection module + * ```js + * PfV6Icon.resolve = (set, icon) => + * import(`/assets/icons.js`) + * .then(module => module[icon]?.cloneNode(true)); + * ``` + * @example resolving a new node from an svg file + * ```js + * const iconCacne = new Map(); + * function getCachedIconOrNewNode(set, icon, svg) { + * const key = `${set}_${icon}`; + * if (!iconCache.has(key)) { + * const template = document.createElement('template'); + * template.innerHTML = svg; + * iconCache.set(key, template); + * } + * return iconCache.get(key); + * } + * PfV6Icon.resolve = (set, icon) => + * fetch(`/assets/icons/${set}/${icon}.svg`) + * .then(response => response.text()) + * .then(svg => getCachedIconOrNewNode(set, icon, svg)) + * .then(node => node.content.cloneNode(true)); + * ``` + */ + public static resolve: IconResolverFunction = PfV6Icon.defaultResolve; + + private static resolvers = new Map(); + + private static instances = new Set(); + + /** + * Register a new icon set + * @param setName - The name of the icon set + * @param resolver - A function that returns the URL of an icon + * @example returning a URL object + * ```js + * PfV6Icon.addIconSet('rh', (set, icon) => + * new URL(`./icons/${set}/${icon}.js`, import.meta.url)); + * ``` + * @example returning a string + * ```js + * PfV6Icon.addIconSet('rh', (set, icon) => + * `/assets/icons/${set}/${icon}.js`); + * ``` + */ + public static addIconSet(setName: string, resolver: IconResolverFunction): void { + if (typeof setName !== 'string') { + Logger.warn(`[${this.name}]: the first argument to addIconSet must be a string.`); + } else if (typeof resolver !== 'function') { + Logger.warn(`[${this.name}]: the second argument to addIconSet must be a function.`); + } else { + this.resolvers.set(setName, resolver); + for (const instance of this.instances) { + instance.#load(); + } + } + } + + /** Removes all added icon sets and resets resolve function */ + public static reset(): void { + this.resolvers.clear(); + this.resolve = this.defaultResolve; + } + + /** Icon set */ + @property() set = 'fas'; + + /** Icon name */ + @property({ reflect: true }) icon?: string; + + /** Size of the icon and its container */ + @property({ reflect: true }) size?: IconSize; + + /** Size of the icon, overriding `size` for the icon alone */ + @property({ attribute: 'icon-size', reflect: true }) iconSize?: IconSize; + + /** Status color of the icon */ + @property({ reflect: true }) status?: IconStatus; + + /** Whether the icon is inline, inheriting the surrounding text's size and color */ + @property({ type: Boolean, reflect: true }) inline = false; + + /** Whether the icon is in progress, replacing the icon with a progress indicator */ + @property({ type: Boolean, attribute: 'in-progress', reflect: true }) inProgress = false; + + /** Whether to mirror the icon horizontally in right-to-left languages */ + @property({ type: Boolean, attribute: 'mirror-rtl', reflect: true }) mirrorRtl = false; + + /** + * Controls how eager the element will be to load the icon data + * - `eager`: eagerly load the icon, blocking the main thread + * - `idle`: wait for the browser to attain an idle state before loading + * - `lazy` (default): wait for the element to enter the viewport before loading + */ + @property() loading?: 'idle' | 'lazy' | 'eager' = 'lazy'; + + /** + * Accessible name for the icon. When set, the icon is exposed to + * assistive technology as an image with this name. When unset, the icon + * is decorative and hidden from the accessibility tree. + */ + @property({ attribute: 'accessible-label' }) accessibleLabel?: string; + + /** Icon content. Any value that lit can render */ + @state() private content?: Renderable; + + #intersecting = false; + + #logger = new Logger(this); + + #internals = InternalsController.of(this); + + override connectedCallback(): void { + super.connectedCallback(); + PfV6Icon.instances.add(this); + } + + override willUpdate(changed: PropertyValues): void { + if (changed.has('icon') || changed.has('set')) { + this.#load(); + } + if (changed.has('accessibleLabel') || changed.has('inProgress')) { + // While in progress, the faded icon keeps its `img` semantics hidden + // from assistive technology, so that the progress indicator's + // announcement (e.g. "Loading...") is not flattened away by the + // `img` role, which presents its subtree as a single image. + const named = !!this.accessibleLabel && !this.inProgress; + this.#internals.role = named ? 'img' : null; + this.#internals.ariaLabel = named ? this.accessibleLabel! : null; + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + PfV6Icon.io?.unobserve(this); + PfV6Icon.instances.delete(this); + } + + override render(): TemplateResult { + return html` + + + + + + + + + `; + } + + /** + * Loads the icon content by resolving the icon name against the icon set, + * then rendering the result. Called automatically when the `icon` + * attribute changes. + */ + protected async load(): Promise { + if (isServer) { + return; + } + const { set, icon } = this; + const resolver = PfV6Icon.resolvers.get(set) ?? PfV6Icon.resolve; + if (set && icon && typeof resolver === 'function') { + try { + this.content = await resolver(set, icon); + await this.updateComplete; + this.dispatchEvent(new Event('load')); + } catch (error: unknown) { + this.#logger.error((error as Error).message); + this.dispatchEvent(new IconResolveError(set, icon, error as Error)); + } + } + } + + #lazyLoad() { + PfV6Icon.io ??= new IntersectionObserver(PfV6Icon.onIntersect); + PfV6Icon.io.observe(this); + if (this.#intersecting) { + this.load(); + } + } + + #load() { + if (isServer) { + return; + } + switch (this.loading?.toLowerCase()) { + case 'idle': return void ric(() => this.load()); + case 'lazy': return void this.#lazyLoad(); + case 'eager': return void this.load(); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-v6-icon': PfV6Icon; + } +} diff --git a/elements/pf-v6-icon/test/.gitignore b/elements/pf-v6-icon/test/.gitignore new file mode 100644 index 0000000000..fca7e160c0 --- /dev/null +++ b/elements/pf-v6-icon/test/.gitignore @@ -0,0 +1 @@ +!rh-icon-*.js diff --git a/elements/pf-v6-icon/test/pf-v6-icon.e2e.ts b/elements/pf-v6-icon/test/pf-v6-icon.e2e.ts new file mode 100644 index 0000000000..e36756f31c --- /dev/null +++ b/elements/pf-v6-icon/test/pf-v6-icon.e2e.ts @@ -0,0 +1,30 @@ +import { test } from '@playwright/test'; +import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js'; +import { SSRPage } from '@patternfly/pfe-tools/test/playwright/SSRPage.js'; + +const tagName = 'pf-v6-icon'; + +test.describe(tagName, () => { + test('snapshot', async ({ page }) => { + const componentPage = new PfeDemoPage(page, tagName); + await componentPage.navigate(); + // Icons load lazily after the element upgrades; wait for the SVG content + // so that the snapshot does not capture empty fallbacks. + await page.waitForFunction(() => + [...document.querySelectorAll('pf-v6-icon')] + .every(icon => icon.shadowRoot?.querySelector('svg'))); + await componentPage.snapshot(); + }); + + test('ssr', async ({ browser }) => { + const fixture = new SSRPage({ + tagName, + browser, + demoDir: new URL('../demo/', import.meta.url), + importSpecifiers: [ + `@patternfly/elements/${tagName}/${tagName}.js`, + ], + }); + await fixture.snapshots(); + }); +}); diff --git a/elements/pf-v6-icon/test/pf-v6-icon.spec.ts b/elements/pf-v6-icon/test/pf-v6-icon.spec.ts new file mode 100644 index 0000000000..749c1f7f3a --- /dev/null +++ b/elements/pf-v6-icon/test/pf-v6-icon.spec.ts @@ -0,0 +1,450 @@ +import { expect, oneEvent } from '@open-wc/testing'; +import { html, render, type TemplateResult } from 'lit'; + +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; + +import { PfV6Icon } from '@patternfly/elements/pf-v6-icon/pf-v6-icon.js'; +import '@patternfly/elements/pf-v6-spinner/pf-v6-spinner.js'; + +import '@patternfly/pfe-tools/test/stub-logger.js'; + +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-ignore: don't want to include these +import aed from './rh-icon-aed.js'; +// @ts-ignore: don't want to include these +import api from './rh-icon-api.js'; +// @ts-ignore: don't want to include these +import atom from './rh-icon-atom.js'; +// @ts-ignore: don't want to include these +import bike from './rh-icon-bike.js'; + +/* eslint-enable @typescript-eslint/ban-ts-comment */ + +const TEST_ICONS = { aed, api, atom, bike }; + +async function expectIconsEqual(element: PfV6Icon, tpl: TemplateResult) { + await oneEvent(element, 'load'); + const rootNode = render(tpl, document.createDocumentFragment()); + const actual = element.shadowRoot?.querySelector('svg'); + const expected = (rootNode.parentNode as DocumentFragment).querySelector('svg'); + expect(actual?.outerHTML).to.equal(expected?.outerHTML); +} + +describe('', function() { + afterEach(function() { + PfV6Icon.reset(); + }); + + it('imperatively instantiates', function() { + expect(document.createElement('pf-v6-icon')).to.be.an.instanceof(PfV6Icon); + }); + + it('should upgrade', async function() { + const element = await createFixture(html``); + expect(element, 'pf-v6-icon should be an instance of PfV6Icon') + .to.be.an.instanceOf(customElements.get('pf-v6-icon')) + .and + .to.be.an.instanceOf(PfV6Icon); + }); + + describe('addIconSet', function() { + describe('when 1st argument is not a string', function() { + beforeEach(function() { + PfV6Icon.addIconSet( + // @ts-expect-error: testing bad input + {}, + () => void 0, + ); + }); + it('should warn', function() { + expect(Logger.warn).to.have.been.calledOnceWith(`[PfV6Icon]: the first argument to addIconSet must be a string.`); + }); + }); + + describe('when 2nd argument is not a function', function() { + beforeEach(function() { + PfV6Icon.addIconSet( + 'rh', + // @ts-expect-error: testing bad input + 'haha' + ); + }); + it('should warn', function() { + expect(Logger.warn).to.have.been.calledOnceWith(`[PfV6Icon]: the second argument to addIconSet must be a function.`); + }); + }); + + describe('with a good resolve function', function() { + let element: PfV6Icon; + beforeEach(async function() { + // replace the default built-in icon set resolveIconName function + // with one that loads local icons. we don't want tests dependent on + // prod servers. + PfV6Icon.addIconSet('rh', (_, icon: string) => TEST_ICONS[icon as keyof typeof TEST_ICONS]); + element = await createFixture(html``); + }); + + for (const [iconName, icon] of Object.entries(TEST_ICONS)) { + it('loads icons', async function() { + element.icon = iconName; + await expectIconsEqual(element, icon); + }); + } + + it('should hide the fallback when it successfully upgrades', async function() { + element.innerHTML = `

Icon failed to load.

`; + element.icon = 'bike'; + await oneEvent(element, 'load'); + const snapshot = await a11ySnapshot(); + expect(snapshot.children).to.not.exist; + }); + + it('is accessible', async function() { + element.icon = 'atom'; + await oneEvent(element, 'load'); + await expect(element).to.be.accessible(); + }); + }); + }); + + describe('default state', function() { + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html``); + }); + + it('does not sprout a size attribute by default', function() { + expect(element.hasAttribute('size')).to.be.false; + }); + + it('does not sprout an icon attribute by default', function() { + expect(element.hasAttribute('icon')).to.be.false; + }); + + it('renders at the body-default size', function() { + const { fontSize } = getComputedStyle(document.documentElement); + const expected = 0.875 * parseFloat(fontSize); + expect(element.offsetWidth).to.equal(expected); + expect(element.offsetHeight).to.equal(expected); + }); + + it('is decorative by default', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children).to.not.exist; + }); + + it('hides a loaded decorative icon from assistive technology', async function() { + const element = await createFixture(html``); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + const snapshot = await a11ySnapshot(); + expect(snapshot.children).to.not.exist; + }); + }); + + describe('accessible-label attribute', function() { + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html` + `); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + }); + + it('exposes the icon as a labelled image', async function() { + const snapshot = await a11ySnapshot(); + const imageNode = snapshot.children?.find( + (child: { role: string }) => child.role === 'image' + ); + expect(imageNode).to.exist; + expect(imageNode?.name).to.equal('Atom'); + }); + + it('does not expose the icon svg as a nested image', async function() { + const snapshot = await a11ySnapshot(); + const images = snapshot.children?.filter( + (child: { role: string }) => child.role === 'image' + ) ?? []; + expect(images.length, 'exactly one image node').to.equal(1); + expect(images[0]?.children, 'no nested nodes under the image').to.not.exist; + }); + }); + + describe('changing size attribute', function() { + const sizes: PfV6Icon['size'][] = [ + 'sm', 'md', 'lg', 'xl', '2xl', '3xl', + 'body-sm', 'body-default', 'body-lg', + 'heading-sm', 'heading-md', 'heading-lg', 'heading-xl', 'heading-2xl', 'heading-3xl', + ]; + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html``); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + }); + + it('sizes the container and the icon together', async function() { + const { fontSize } = getComputedStyle(document.documentElement); + const expected = [ + 0.75, 0.875, 1, 1.5, 3.5, 6, + 0.75, 0.875, 1, + 1, 1, 1, 1.125, 1.25, 1.5, + ].map(px => px * parseFloat(fontSize)); + for (const [i, size] of sizes.entries()) { + element.size = size; + await element.updateComplete; + expect(element.offsetWidth, `size "${size}" container width`) + .to.equal(expected[i]); + } + }); + }); + + describe('changing set attribute', function() { + let element: PfV6Icon; + beforeEach(async function() { + PfV6Icon.addIconSet('rh', (_, icon: string) => TEST_ICONS[icon as keyof typeof TEST_ICONS]); + element = await createFixture(html``); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + }); + + it('re-resolves the icon when the set changes', async function() { + element.set = 'rh'; + await expectIconsEqual(element, atom); + }); + }); + + describe('mirror-rtl attribute', function() { + let element: PfV6Icon; + beforeEach(async function() { + const container = await createFixture(html` +
+ + + +
`); + element = container.querySelector('pf-v6-icon')!; + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + await element.updateComplete; + }); + + it('mirrors the icon in rtl direction', async function() { + const svg = element.shadowRoot!.querySelector('#content svg')!; + expect(getComputedStyle(svg).scale).to.equal('-1 1'); + }); + + it('does not mirror the progress icon', async function() { + const spinner = element.shadowRoot!.querySelector('pf-v6-spinner')!; + const svg = spinner.shadowRoot!.querySelector('svg')!; + expect(getComputedStyle(svg).scale).to.equal('none'); + }); + + it('does not mirror the icon in ltr direction', async function() { + element.parentElement!.setAttribute('dir', 'ltr'); + const svg = element.shadowRoot!.querySelector('#content svg')!; + expect(getComputedStyle(svg).scale).to.equal('none'); + }); + }); + + describe('icon-size attribute', function() { + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html` + `); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + }); + + it('sizes the icon without resizing the container', async function() { + const { fontSize } = getComputedStyle(document.documentElement); + const rootFontSize = parseFloat(fontSize); + expect(element.offsetWidth, 'container width').to.equal(6 * rootFontSize); + const svg = element.shadowRoot!.querySelector('svg')!; + expect(svg.getBoundingClientRect().width, 'icon width').to.equal(0.875 * rootFontSize); + }); + }); + + describe('status attribute', function() { + const cases: [PfV6Icon['status'], string][] = [ + ['danger', 'light-dark(#b1380b, #f0561d)'], + ['warning', 'light-dark(#dca614, #ffcc17)'], + ['success', 'light-dark(#3d7317, #87bb62)'], + ['info', 'light-dark(#5e40be, #b6a6e9)'], + ['custom', 'light-dark(#147878, #63bdbd)'], + ]; + + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html``); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + }); + + for (const [status, expected] of cases) { + it(`sets the icon color when status is ${status}`, async function() { + element.status = status; + await element.updateComplete; + const color = getComputedStyle(element) + .getPropertyValue('--pf-v6-c-icon__content--Color'); + expect(color.trim()).to.equal(expected); + }); + } + }); + + describe('inline attribute', function() { + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html` + `); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + }); + + it('sizes the icon relative to the surrounding text', async function() { + const { fontSize } = getComputedStyle(element); + expect(element.offsetWidth).to.equal(parseFloat(fontSize)); + }); + + it('inherits the surrounding text color', function() { + const color = getComputedStyle(element) + .getPropertyValue('--pf-v6-c-icon__content--Color'); + expect(color.trim()).to.equal(''); + }); + }); + + describe('in-progress attribute', function() { + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html` + `); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + }); + + it('replaces the icon with a spinner in the accessibility tree', async function() { + const snapshot = await a11ySnapshot(); + const spinnerNode = snapshot.children?.find( + (child: { role: string }) => child.role === 'progressbar' + ); + expect(spinnerNode).to.exist; + expect(spinnerNode?.valuetext).to.equal('Loading...'); + }); + + it('removes the spinner when no longer in progress', async function() { + element.inProgress = false; + await element.updateComplete; + const snapshot = await a11ySnapshot(); + expect(snapshot.children).to.not.exist; + }); + + it('announces the spinner instead of the name while in progress', async function() { + element.accessibleLabel = 'Atom'; + await element.updateComplete; + const snapshot = await a11ySnapshot(); + const imageNode = snapshot.children?.find( + (child: { role: string }) => child.role === 'image' + ); + expect(imageNode, 'image node is hidden while in progress').to.not.exist; + const spinnerNode = snapshot.children?.find( + (child: { role: string }) => child.role === 'progressbar' + ); + expect(spinnerNode?.valuetext).to.equal('Loading...'); + }); + + it('restores the accessible name when no longer in progress', async function() { + element.accessibleLabel = 'Atom'; + element.inProgress = false; + await element.updateComplete; + const snapshot = await a11ySnapshot(); + const imageNode = snapshot.children?.find( + (child: { role: string }) => child.role === 'image' + ); + expect(imageNode?.name).to.equal('Atom'); + }); + }); + + describe('custom progress icon', function() { + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html` + + + `); + PfV6Icon.addIconSet('fas', () => atom); + await oneEvent(element, 'load'); + }); + + it('uses the slotted element as the progress indicator', async function() { + const snapshot = await a11ySnapshot(); + const spinnerNode = snapshot.children?.find( + (child: { role: string }) => child.role === 'progressbar' + ); + expect(spinnerNode).to.exist; + expect(spinnerNode?.name).to.equal('Loading results'); + }); + }); + + describe('when the icon has a custom set attribute', function() { + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html``); + }); + describe('then the icon set is registered', function() { + beforeEach(async function() { + PfV6Icon.addIconSet('asdfasdf', () => + import(`./rh-icon-${'bike'}.js`) + .then(m => m.default)); + await oneEvent(element, 'load'); + }); + it(`should render the icon`, function() { + expectIconsEqual(element, bike); + }); + }); + }); + + describe('when the icon has a fallback content', function() { + let element: PfV6Icon; + beforeEach(async function() { + element = await createFixture(html` + +

Image failed to load.

. +
`); + }); + it('should display the fallback', async function() { + const errorEvent = oneEvent(element, 'error'); + element.icon = 'no-scrubs'; + await errorEvent; + expect(element.shadowRoot!.querySelector('svg')).to.not.be.ok; + const fallback = element.shadowRoot!.querySelector('[part=fallback]'); + expect(fallback).to.not.have.attribute('hidden'); + }); + + it('exposes fallback text when the icon is decorative', async function() { + const errorEvent = oneEvent(element, 'error'); + element.icon = 'no-scrubs'; + await errorEvent; + const snapshot = await a11ySnapshot(); + const text = snapshot.children?.find( + (child: { role?: string }) => child.role !== 'image' + ); + expect(text).to.exist; + }); + + it('hides fallback text from the labelled image', async function() { + element.accessibleLabel = 'Labelled fallback'; + await element.updateComplete; + const errorEvent = oneEvent(element, 'error'); + element.icon = 'no-scrubs'; + await errorEvent; + const snapshot = await a11ySnapshot(); + const imageNode = snapshot.children?.find( + (child: { role: string }) => child.role === 'image' + ); + expect(imageNode?.name).to.equal('Labelled fallback'); + expect(snapshot.children?.length, 'only the labelled image node').to.equal(1); + }); + }); +}); diff --git a/elements/pf-v6-icon/test/rh-icon-aed.js b/elements/pf-v6-icon/test/rh-icon-aed.js new file mode 100644 index 0000000000..6d31054a1b --- /dev/null +++ b/elements/pf-v6-icon/test/rh-icon-aed.js @@ -0,0 +1,2 @@ +import { svg } from 'lit'; +export default svg``; diff --git a/elements/pf-v6-icon/test/rh-icon-api.js b/elements/pf-v6-icon/test/rh-icon-api.js new file mode 100644 index 0000000000..d1fcfb7101 --- /dev/null +++ b/elements/pf-v6-icon/test/rh-icon-api.js @@ -0,0 +1,2 @@ +import { svg } from 'lit'; +export default svg``; diff --git a/elements/pf-v6-icon/test/rh-icon-atom.js b/elements/pf-v6-icon/test/rh-icon-atom.js new file mode 100644 index 0000000000..9f27ab3967 --- /dev/null +++ b/elements/pf-v6-icon/test/rh-icon-atom.js @@ -0,0 +1,2 @@ +import { svg } from 'lit'; +export default svg``; diff --git a/elements/pf-v6-icon/test/rh-icon-bike.js b/elements/pf-v6-icon/test/rh-icon-bike.js new file mode 100644 index 0000000000..3d7c0f7c42 --- /dev/null +++ b/elements/pf-v6-icon/test/rh-icon-bike.js @@ -0,0 +1,2 @@ +import { svg } from 'lit'; +export default svg``; diff --git a/tools/pfe-tools/test/playwright/PfeDemoPage.ts b/tools/pfe-tools/test/playwright/PfeDemoPage.ts index ca7b9c119d..cebd598079 100644 --- a/tools/pfe-tools/test/playwright/PfeDemoPage.ts +++ b/tools/pfe-tools/test/playwright/PfeDemoPage.ts @@ -24,7 +24,10 @@ export class PfeDemoPage { const selectorOverride = typeof pathnameOrOptions === 'string' ? undefined : pathnameOrOptions?.selector; - const slug = this.tagName.replace(/^pf-(?:v\d+-)?/, ''); + // cem serve routes `pf-v5-*` elements at `/components/{name}/demo`, but + // `pf-v6-*` elements at `/components/v6-{name}/demo`, so only the `pf-` + // prefix and the `v5-` version prefix are stripped from the demo route slug. + const slug = this.tagName.replace(/^pf-/, '').replace(/^v5-/, ''); const pathname = typeof pathnameOrOptions === 'string' ? pathnameOrOptions : `${this.workspace}/${slug}/demo`;