diff --git a/.changeset/dirty-bears-win.md b/.changeset/dirty-bears-win.md new file mode 100644 index 0000000000..fe7f079524 --- /dev/null +++ b/.changeset/dirty-bears-win.md @@ -0,0 +1,34 @@ +--- +"@patternfly/elements": major +--- +``: removed the `getIconUrl` static method, and replaced it with the +`resolve` static method + +The steps for overriding icon loading behaviour have changed. Before, you had to +return a string from the `getIconUrl` method, or the second argument to +`addIconSet`. Now, both of those functions must return a Node, or any lit-html +renderable value, or a Promise thereof. + +BEFORE: + +```js +PfIcon.addIconSet('local', (set, icon) => + new URL(`/assets/icons/${set}-${icon}.js`)); + +// or +PfIcon.getIconUrl = (set, icon) => + new URL(`/assets/icons/${set}-${icon}.js`)) +``` + +AFTER +```js +PfIcon.addIconSet('local', (set, icon) => + import(`/assets/icons/${set}-${icon}.js`)) + .then(mod => mod.default); + +// or +PfIcon.resolve = (set, icon) => + import(`/assets/icons/${set}-${icon}.js`)) + .then(mod => mod.default); +``` + diff --git a/.changeset/fresh-shrimps-work.md b/.changeset/fresh-shrimps-work.md new file mode 100644 index 0000000000..0e550d4716 --- /dev/null +++ b/.changeset/fresh-shrimps-work.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": major +--- +``: removed the `defaultIconSet` static field. diff --git a/.changeset/heavy-peas-appear.md b/.changeset/heavy-peas-appear.md new file mode 100644 index 0000000000..0df3aec13c --- /dev/null +++ b/.changeset/heavy-peas-appear.md @@ -0,0 +1,18 @@ +--- +"@patternfly/pfe-tools": minor +--- +Added `querySnapshot` accessibility testing helper + +```ts + +describe('then clicking the toggle', function() { + beforeEach(async function() { + await clickElementAtCenter(toggle); + }); + it('expands the disclosure panel', async function() { + const snapshot = await a11ySnapshot(); + const expanded = querySnapshot(snapshot, { expanded: true }); + expect(expanded).to.be.ok; + }); +}); +``` diff --git a/.changeset/hip-coins-prove.md b/.changeset/hip-coins-prove.md new file mode 100644 index 0000000000..861aa6a252 --- /dev/null +++ b/.changeset/hip-coins-prove.md @@ -0,0 +1,32 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseAccordion*` classes, as well as static `isPanel`, `isHeader`, and `isAccordion` methods. Removed the optional `parentAccordion` parameter to `PfAccordion#expand(index)`. Renamed accordion event classes by adding the `Pf` prefix: + +**Before**: + +```js +import { + AccordionHeaderChangeEvent +} from '@patternfly/elements/pf-accordion/pf-accordion.js'; + +addEventListener('change', function(event) { + if (event instanceof AccordionHeaderChangeEvent) { + // ... + } +}); +``` + +**After**: + +```js +import { + PfAccordionHeaderChangeEvent +} from '@patternfly/elements/pf-accordion/pf-accordion.js'; + +addEventListener('change', function(event) { + if (event instanceof PfAccordionHeaderChangeEvent) { + // ... + } +}); +``` diff --git a/.changeset/huge-mice-build.md b/.changeset/huge-mice-build.md new file mode 100644 index 0000000000..b7479523a0 --- /dev/null +++ b/.changeset/huge-mice-build.md @@ -0,0 +1,4 @@ +--- +"@patternfly/pfe-tools": patch +--- +**Dev Server**: load lightdom shim files diff --git a/.changeset/legal-chairs-double.md b/.changeset/legal-chairs-double.md new file mode 100644 index 0000000000..fc4f2d1a67 --- /dev/null +++ b/.changeset/legal-chairs-double.md @@ -0,0 +1,4 @@ +--- +"@patternfly/pfe-tools": patch +--- +**Dev Server**: reload on typescript file changes diff --git a/.changeset/odd-months-flow.md b/.changeset/odd-months-flow.md deleted file mode 100644 index ddbb290920..0000000000 --- a/.changeset/odd-months-flow.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -"@patternfly/pfe-tools": patch ---- -Dev Server: redirect demo links to css lightdom subresources diff --git a/.changeset/purple-boxes-stare.md b/.changeset/purple-boxes-stare.md new file mode 100644 index 0000000000..6b08160214 --- /dev/null +++ b/.changeset/purple-boxes-stare.md @@ -0,0 +1,5 @@ +--- +"@patternfly/elements": patch +--- + +``: fix hover color diff --git a/.changeset/shaky-cats-share.md b/.changeset/shaky-cats-share.md new file mode 100644 index 0000000000..8d0fd49f18 --- /dev/null +++ b/.changeset/shaky-cats-share.md @@ -0,0 +1,4 @@ +--- +"@patternfly/create-element": patch +--- +Element generator now generates demo files with inlined script and styles diff --git a/.changeset/slick-bats-brake.md b/.changeset/slick-bats-brake.md new file mode 100644 index 0000000000..6e09899203 --- /dev/null +++ b/.changeset/slick-bats-brake.md @@ -0,0 +1,12 @@ +--- +"@patternfly/pfe-tools": minor +--- +**TypeScript**: Add static version transformer. This adds a runtime-only +static `version` field to custom element classes. + +```js +import '@patternfly/elements/pf-button/pf-button.js'; +const PFE_VERSION = + await customElements.whenDefined('pf-button') + .then(PfButton => PfButton.version); +``` diff --git a/.changeset/upset-birds-mix.md b/.changeset/upset-birds-mix.md deleted file mode 100644 index 151c29af59..0000000000 --- a/.changeset/upset-birds-mix.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -"@patternfly/elements": patch ---- -``: corrected size of copy button diff --git a/docs/_data/importMap.cjs b/docs/_data/importMap.cjs index 6214503d9f..236a591b12 100644 --- a/docs/_data/importMap.cjs +++ b/docs/_data/importMap.cjs @@ -77,7 +77,7 @@ module.exports = async function() { const { Generator } = await import('@jspm/generator'); const generator = new Generator({ - defaultProvider: 'jspm.io', + defaultProvider: 'jsdelivr', env: ['production', 'browser', 'module'], }); diff --git a/elements/CHANGELOG.md b/elements/CHANGELOG.md index 7fc772e199..3561581ff0 100644 --- a/elements/CHANGELOG.md +++ b/elements/CHANGELOG.md @@ -1,5 +1,11 @@ # @patternfly/elements +## 3.0.2 + +### Patch Changes + +- 9702278: ``: corrected size of copy button + ## 3.0.1 ### Patch Changes diff --git a/elements/package.json b/elements/package.json index 179fcbfae9..4110cfa127 100644 --- a/elements/package.json +++ b/elements/package.json @@ -1,7 +1,7 @@ { "name": "@patternfly/elements", "license": "MIT", - "version": "3.0.1", + "version": "3.0.2", "description": "PatternFly Elements", "customElements": "custom-elements.json", "type": "module", @@ -125,11 +125,13 @@ "Nikki Massaro Kauffman (https://github.com/nikkimk)", "Steven Spriggs " ], "dependencies": { "@lit/context": "^1.1.0", - "@patternfly/icons": "^1.0.2", + "@patternfly/icons": "^1.0.3", "@patternfly/pfe-core": "^3.0.0", "lit": "^3.1.2", "tslib": "^2.6.2" diff --git a/elements/pf-accordion/BaseAccordionHeader.css b/elements/pf-accordion/BaseAccordionHeader.css deleted file mode 100644 index d6de7bfbfe..0000000000 --- a/elements/pf-accordion/BaseAccordionHeader.css +++ /dev/null @@ -1,39 +0,0 @@ -#heading { - font-size: 100%; - padding: 0; - margin: 0; -} - -button, -a { - cursor: pointer; -} - -.toggle, -.toggle:before, -.toggle:after { - padding: 0; - margin: 0; -} - -.toggle { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - border: 0; -} - -.toggle:after { - content: ""; - position: absolute; - bottom: 0; - left: 0; -} - -span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/elements/pf-accordion/BaseAccordionHeader.ts b/elements/pf-accordion/BaseAccordionHeader.ts deleted file mode 100644 index 07e5e1f9dd..0000000000 --- a/elements/pf-accordion/BaseAccordionHeader.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { TemplateResult } from 'lit'; - -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators/property.js'; - -import { BaseAccordion } from './BaseAccordion.js'; -import { ComposedEvent } from '@patternfly/pfe-core'; -import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; -import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; - -import style from './BaseAccordionHeader.css'; - -const isPorHeader = - (el: Node): el is HTMLElement => - el instanceof HTMLElement && !!el.tagName.match(/P|^H[1-6]/); - -export class AccordionHeaderChangeEvent extends ComposedEvent { - declare target: BaseAccordionHeader; - constructor( - public expanded: boolean, - public toggle: BaseAccordionHeader, - public accordion: BaseAccordion - ) { - super('change'); - } -} - -export abstract class BaseAccordionHeader extends LitElement { - static readonly styles = [style]; - - static override readonly shadowRootOptions = { - ...LitElement.shadowRootOptions, - delegatesFocus: true, - }; - - @property({ type: Boolean, reflect: true }) expanded = false; - - @property({ reflect: true, attribute: 'heading-text' }) headingText?: string; - - @property({ reflect: true, attribute: 'heading-tag' }) headingTag?: string; - - #generatedHtag?: HTMLHeadingElement; - - #logger = new Logger(this); - - #header?: HTMLElement; - - override connectedCallback() { - super.connectedCallback(); - this.addEventListener('click', this.#onClick); - this.hidden = true; - this.id ||= getRandomId(this.localName); - this.#initHeader(); - } - - async #initHeader() { - if (this.headingText && !this.headingTag) { - this.headingTag = 'h3'; - } - this.#header = this.#getOrCreateHeader(); - - // prevent double-logging - if (this.#header !== this.#generatedHtag) { - this.#generatedHtag = undefined; - } - - do { - await this.updateComplete; - } while (!await this.updateComplete); - - // Remove the hidden attribute after upgrade - this.hidden = false; - } - - /** Template hook: before */ - renderAfterButton?(): TemplateResult; - - override render(): TemplateResult { - switch (this.headingTag) { - case 'h1': return html`

${this.#renderHeaderContent()}

`; - case 'h2': return html`

${this.#renderHeaderContent()}

`; - case 'h3': return html`

${this.#renderHeaderContent()}

`; - case 'h4': return html`

${this.#renderHeaderContent()}

`; - case 'h5': return html`
${this.#renderHeaderContent()}
`; - case 'h6': return html`
${this.#renderHeaderContent()}
`; - default: return this.#renderHeaderContent(); - } - } - - #renderHeaderContent() { - const headingText = this.headingText?.trim() ?? this.#header?.textContent?.trim(); - return html` - - `; - } - - #getOrCreateHeader(): HTMLElement | undefined { - // Check if there is no nested element or nested textNodes - if (!this.firstElementChild && !this.firstChild) { - return void this.#logger.warn('No header content provided'); - } else if (this.firstElementChild) { - const [heading, ...otherContent] = Array.from(this.children) - .filter((x): x is HTMLElement => !x.hasAttribute('slot') && isPorHeader(x)); - - // If there is no content inside the slot, return empty with a warning - // else, if there is more than 1 element in the slot, capture the first h-tag - if (!heading) { - return void this.#logger.warn('No heading information was provided.'); - } else if (otherContent.length) { - this.#logger.warn('Heading currently only supports 1 tag; extra tags will be ignored.'); - } - return heading; - } else { - if (!this.#generatedHtag) { - this.#logger.warn('Header should contain at least 1 heading tag for correct semantics.'); - } - this.#generatedHtag = document.createElement('h3'); - - // If a text node was provided but no semantics, default to an h3 - // otherwise, incorrect semantics were used, create an H3 and try to capture the content - if (this.firstChild?.nodeType === Node.TEXT_NODE) { - this.#generatedHtag.textContent = this.firstChild.textContent; - } else { - this.#generatedHtag.textContent = this.textContent; - } - - return this.#generatedHtag; - } - } - - #onClick(event: MouseEvent) { - const expanded = !this.expanded; - const acc = event.composedPath().find(BaseAccordion.isAccordion); - if (acc) { - this.dispatchEvent(new AccordionHeaderChangeEvent(expanded, this, acc)); - } - } -} diff --git a/elements/pf-accordion/BaseAccordionPanel.css b/elements/pf-accordion/BaseAccordionPanel.css deleted file mode 100644 index da1f4f4f89..0000000000 --- a/elements/pf-accordion/BaseAccordionPanel.css +++ /dev/null @@ -1,27 +0,0 @@ -:host { - display: none; - overflow: hidden; - will-change: height; -} - -:host([expanded]) { - display: block; - position: relative; -} - -:host([fixed]) { - overflow-y: auto; -} - -.body { - position: relative; - overflow: hidden; -} - -.body:after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; -} diff --git a/elements/pf-accordion/BaseAccordionPanel.ts b/elements/pf-accordion/BaseAccordionPanel.ts deleted file mode 100644 index ba8067eec9..0000000000 --- a/elements/pf-accordion/BaseAccordionPanel.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators/property.js'; - -import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; - -import style from './BaseAccordionPanel.css'; - -export class BaseAccordionPanel extends LitElement { - static readonly styles = [style]; - - @property({ type: Boolean, reflect: true }) expanded = false; - - connectedCallback() { - super.connectedCallback(); - this.id ||= getRandomId(this.localName); - this.setAttribute('role', 'region'); - } - - override render() { - return html` -
-
-
- -
-
-
- `; - } -} diff --git a/elements/pf-accordion/demo/single-expanded-panel.html b/elements/pf-accordion/demo/single-expanded-panel.html index 164fa17229..97b4e07160 100644 --- a/elements/pf-accordion/demo/single-expanded-panel.html +++ b/elements/pf-accordion/demo/single-expanded-panel.html @@ -1,58 +1,49 @@ -
- - -

Level One - Item one

-
- -

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua.

-
- -

Level One - Item two

-
- -

Vivamus et tortor sed arcu congue vehicula eget et diam. Praesent nec dictum lorem. Aliquam id diam ultrices, - faucibus erat id, maximus nunc.

-
- -

Level One - Item three

-
- -

Morbi vitae urna quis nunc convallis hendrerit. Aliquam congue orci quis ultricies tempus.

-
- -

Level One - Item four

-
- -

- Donec vel posuere orci. Phasellus quis tortor a ex hendrerit efficitur. Aliquam lacinia ligula pharetra, - sagittis ex ut, pellentesque diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere - cubilia Curae; - Vestibulum ultricies nulla nibh. Etiam vel dui fermentum ligula ullamcorper eleifend non quis tortor. Morbi - tempus ornare tempus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - Mauris - et velit neque. Donec ultricies condimentum mauris, pellentesque imperdiet libero convallis convallis. Aliquam - erat volutpat. Donec rutrum semper tempus. Proin dictum imperdiet nibh, quis dapibus nulla. Integer sed - tincidunt - lectus, sit amet auctor eros. -

-
- -

Level One - Item five

-
- -

Vivamus finibus dictum ex id ultrices. Mauris dictum neque a iaculis blandit.

-
-
-
- + + +

Level One - Item one

+
+ +

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua.

+
+ +

Level One - Item two

+
+ +

Vivamus et tortor sed arcu congue vehicula eget et diam. Praesent nec dictum lorem. Aliquam id diam ultrices, + faucibus erat id, maximus nunc.

+
+ +

Level One - Item three

+
+ +

Morbi vitae urna quis nunc convallis hendrerit. Aliquam congue orci quis ultricies tempus.

+
+ +

Level One - Item four

+
+ +

+ Donec vel posuere orci. Phasellus quis tortor a ex hendrerit efficitur. Aliquam lacinia ligula pharetra, + sagittis ex ut, pellentesque diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere + cubilia Curae; + Vestibulum ultricies nulla nibh. Etiam vel dui fermentum ligula ullamcorper eleifend non quis tortor. Morbi + tempus ornare tempus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. + Mauris + et velit neque. Donec ultricies condimentum mauris, pellentesque imperdiet libero convallis convallis. Aliquam + erat volutpat. Donec rutrum semper tempus. Proin dictum imperdiet nibh, quis dapibus nulla. Integer sed + tincidunt + lectus, sit amet auctor eros. +

+
+ +

Level One - Item five

+
+ +

Vivamus finibus dictum ex id ultrices. Mauris dictum neque a iaculis blandit.

+
+
- - diff --git a/elements/pf-accordion/pf-accordion-header.css b/elements/pf-accordion/pf-accordion-header.css index 9351af8836..cec8e35d07 100644 --- a/elements/pf-accordion/pf-accordion-header.css +++ b/elements/pf-accordion/pf-accordion-header.css @@ -32,11 +32,21 @@ #heading { font-weight: var(--pf-c-accordion__toggle--FontWeight, var(--pf-global--FontWeight--normal, 400)); + font-size: 100%; + padding: 0; + margin: 0; +} + +button, +a { + cursor: pointer; } .toggle, .toggle:before, .toggle:after { + padding: 0; + margin: 0; background-color: var(--pf-c-accordion__toggle--BackgroundColor, transparent); } @@ -45,6 +55,12 @@ } .toggle { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border: 0; padding: var(--pf-c-accordion__toggle--PaddingTop, var(--pf-global--spacer--md, 0.5rem)) var(--pf-c-accordion__toggle--PaddingRight, var(--pf-global--spacer--md, 1rem)) @@ -77,9 +93,16 @@ top: var(--pf-c-accordion__toggle--before--Top, -1px); width: var(--pf-c-accordion__toggle--before--Width, var(--pf-global--BorderWidth--lg, 3px)); background-color: var(--pf-c-accordion__toggle--after--BackgroundColor, transparent); + content: ""; + position: absolute; + bottom: 0; + left: 0; } span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; max-width: var(--pf-c-accordion__toggle-text--MaxWidth, calc(100% - var(--pf-global--spacer--lg, 1.5rem))); } diff --git a/elements/pf-accordion/pf-accordion-header.ts b/elements/pf-accordion/pf-accordion-header.ts index 42347d740c..c6d5f859d5 100644 --- a/elements/pf-accordion/pf-accordion-header.ts +++ b/elements/pf-accordion/pf-accordion-header.ts @@ -1,30 +1,44 @@ -import { html } from 'lit'; +import type { PfAccordion } from './pf-accordion.js'; + +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; -import { BaseAccordionHeader } from './BaseAccordionHeader.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import style from './pf-accordion-header.css'; import '@patternfly/elements/pf-icon/pf-icon.js'; +const isPorHeader = + (el: Node): el is HTMLElement => + el instanceof HTMLElement && !!el.tagName.match(/P|^H[1-6]/); + +export class PfAccordionHeaderChangeEvent extends Event { + declare target: PfAccordionHeader; + constructor( + public expanded: boolean, + public toggle: PfAccordionHeader, + public accordion: PfAccordion + ) { + super('change', { bubbles: true }); + } +} + /** * Accordion Header - * * @csspart text - inline element containing the heading text or slotted heading content * @csspart accents - container for accents within the header * @csspart icon - caret icon - * * @slot * We expect the light DOM of the pf-accordion-header to be a heading level tag (h1, h2, h3, h4, h5, h6) * @slot accents * These elements will appear inline with the accordion header, between the header and the chevron * (or after the chevron and header in disclosure mode). - * * @fires {AccordionHeaderChangeEvent} change - when the open panels change - * * @cssprop {} --pf-c-accordion__toggle--Color * Sets the font color for the accordion header. * {@default `var(--pf-global--Color--100, #151515)`} @@ -81,8 +95,13 @@ import '@patternfly/elements/pf-icon/pf-icon.js'; * {@default `0.2s ease-in 0s`} */ @customElement('pf-accordion-header') -export class PfAccordionHeader extends BaseAccordionHeader { - static readonly styles = [...BaseAccordionHeader.styles, style]; +export class PfAccordionHeader extends LitElement { + static readonly styles = [style]; + + static override readonly shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; @property({ reflect: true }) bordered?: 'true' | 'false'; @@ -90,19 +109,117 @@ export class PfAccordionHeader extends BaseAccordionHeader { @property({ reflect: true, attribute: 'icon-set' }) iconSet?: string; + @property({ type: Boolean, reflect: true }) expanded = false; + + @property({ reflect: true, attribute: 'heading-text' }) headingText?: string; + + @property({ reflect: true, attribute: 'heading-tag' }) headingTag?: string; + + #generatedHtag?: HTMLHeadingElement; + + #logger = new Logger(this); + + #header?: HTMLElement; + #slots = new SlotController(this, 'accents', null); - renderAfterButton() { - return html`${!this.#slots.hasSlotted('accents') ? '' : html` - - - `} - + override connectedCallback() { + super.connectedCallback(); + this.hidden = true; + this.id ||= getRandomId(this.localName); + this.#initHeader(); + } + + override render() { + const headingText = this.headingText?.trim() ?? this.#header?.textContent?.trim(); + const content = html` + `; + switch (this.headingTag) { + case 'h1': return html`

${content}

`; + case 'h2': return html`

${content}

`; + case 'h3': return html`

${content}

`; + case 'h4': return html`

${content}

`; + case 'h5': return html`
${content}
`; + case 'h6': return html`
${content}
`; + default: return content; + } + } + + async #initHeader() { + if (this.headingText) { + this.headingTag ||= 'h3'; + } + this.#header = this.#getOrCreateHeader(); + + // prevent double-logging + if (this.#header !== this.#generatedHtag) { + this.#generatedHtag = undefined; + } + + do { + await this.updateComplete; + } while (!await this.updateComplete); + + // Remove the hidden attribute after upgrade + this.hidden = false; + } + + #getOrCreateHeader(): HTMLElement | undefined { + // Check if there is no nested element or nested textNodes + if (!this.firstElementChild && !this.firstChild) { + return void this.#logger.warn('No header content provided'); + } else if (this.firstElementChild) { + const [heading, ...otherContent] = Array.from(this.children) + .filter((x): x is HTMLElement => !x.hasAttribute('slot') && isPorHeader(x)); + + // If there is no content inside the slot, return empty with a warning + // else, if there is more than 1 element in the slot, capture the first h-tag + if (!heading) { + return void this.#logger.warn('No heading information was provided.'); + } else if (otherContent.length) { + this.#logger.warn('Heading currently only supports 1 tag; extra tags will be ignored.'); + } + return heading; + } else { + if (!this.#generatedHtag) { + this.#logger.warn('Header should contain at least 1 heading tag for correct semantics.'); + } + this.#generatedHtag = document.createElement('h3'); + + // If a text node was provided but no semantics, default to an h3 + // otherwise, incorrect semantics were used, create an H3 and try to capture the content + if (this.firstChild?.nodeType === Node.TEXT_NODE) { + this.#generatedHtag.textContent = this.firstChild.textContent; + } else { + this.#generatedHtag.textContent = this.textContent; + } + + return this.#generatedHtag; + } + } + + #onClick() { + const expanded = !this.expanded; + const acc = this.closest('pf-accordion'); + if (acc) { + this.dispatchEvent(new PfAccordionHeaderChangeEvent(expanded, this, acc)); + } } } diff --git a/elements/pf-accordion/pf-accordion-panel.css b/elements/pf-accordion/pf-accordion-panel.css index cf780d1bc4..25e90c75ba 100644 --- a/elements/pf-accordion/pf-accordion-panel.css +++ b/elements/pf-accordion/pf-accordion-panel.css @@ -1,4 +1,8 @@ :host { + display: none; + position: relative; + overflow: hidden; + will-change: height; color: var(--pf-global--Color--100, #151515); background-color: var( @@ -16,6 +20,11 @@ } .body:after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; width: var(--pf-c-accordion__panel-body--before--Width, var(--pf-global--BorderWidth--lg, 3px)); background-color: var(--pf-c-accordion__panel-body--before--BackgroundColor, transparent); } @@ -46,9 +55,15 @@ } :host([fixed]) { + overflow-y: auto; max-height: var(--pf-c-accordion__panel--m-fixed--MaxHeight, 9.375rem); } +:host([expanded]) { + display: block; + position: relative; +} + .content[expanded], :host([expanded]) .content { --pf-c-accordion__panel-body--before--BackgroundColor: diff --git a/elements/pf-accordion/pf-accordion-panel.ts b/elements/pf-accordion/pf-accordion-panel.ts index 90fc37fe8b..30fa296fb6 100644 --- a/elements/pf-accordion/pf-accordion-panel.ts +++ b/elements/pf-accordion/pf-accordion-panel.ts @@ -1,13 +1,13 @@ +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; -import { BaseAccordionPanel } from './BaseAccordionPanel.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import style from './pf-accordion-panel.css'; /** * Accordion Panel - * * @slot - Panel content * @cssprop {} --pf-c-accordion--BackgroundColor * Sets the background color for the panel content. @@ -45,10 +45,30 @@ import style from './pf-accordion-panel.css'; * {@default `var(--pf-global--BorderWidth--lg, 3px)`} */ @customElement('pf-accordion-panel') -export class PfAccordionPanel extends BaseAccordionPanel { - static readonly styles = [...BaseAccordionPanel.styles, style]; +export class PfAccordionPanel extends LitElement { + static readonly styles = [style]; + + @property({ type: Boolean, reflect: true }) expanded = false; @property({ reflect: true }) bordered?: 'true' | 'false'; + + override connectedCallback() { + super.connectedCallback(); + this.id ||= getRandomId(this.localName); + this.setAttribute('role', 'region'); + } + + override render() { + return html` +
+
+
+ +
+
+
+ `; + } } declare global { diff --git a/elements/pf-accordion/pf-accordion.ts b/elements/pf-accordion/pf-accordion.ts index b1de02153c..729f17533c 100644 --- a/elements/pf-accordion/pf-accordion.ts +++ b/elements/pf-accordion/pf-accordion.ts @@ -1,15 +1,38 @@ +import { LitElement, html } from 'lit'; import { observed } from '@patternfly/pfe-core/decorators.js'; import { property } from 'lit/decorators/property.js'; import { customElement } from 'lit/decorators/custom-element.js'; -import { BaseAccordion } from './BaseAccordion.js'; -import { BaseAccordionHeader } from './BaseAccordionHeader.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; +import { NumberListConverter } from '@patternfly/pfe-core'; +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; + +import { PfAccordionHeader, PfAccordionHeaderChangeEvent } from './pf-accordion-header.js'; +import { PfAccordionPanel } from './pf-accordion-panel.js'; export * from './pf-accordion-header.js'; export * from './pf-accordion-panel.js'; import style from './pf-accordion.css'; +export class PfAccordionExpandEvent extends Event { + constructor( + public toggle: PfAccordionHeader, + public panel: PfAccordionPanel, + ) { + super('expand', { bubbles: true, cancelable: true }); + } +} + +export class PfAccordionCollapseEvent extends Event { + constructor( + public toggle: PfAccordionHeader, + public panel: PfAccordionPanel, + ) { + super('collapse', { bubbles: true, cancelable: true }); + } +} + /** * An **accordion** is an interactive container that expands and collapses to hide or reveal nested content. It takes advantage of progressive disclosure to help reduce page scrolling, by allowing users to choose whether they want to show or hide more detailed information as needed. * @summary Toggle the visibility of sections of content @@ -81,7 +104,7 @@ import style from './pf-accordion.css'; * @cssprop --pf-c-accordion--m-bordered__expanded-content--m-expanded__expanded-content-body--last-child--after--BorderBottomColor {@default var(--pf-global--BorderColor--100, #d2d2d2)} */ @customElement('pf-accordion') -export class PfAccordion extends BaseAccordion { +export class PfAccordion extends LitElement { static readonly styles = [style]; /** When true, only one accordion panel may be expanded at a time */ @@ -98,39 +121,304 @@ export class PfAccordion extends BaseAccordion { @property({ type: Boolean, reflect: true }) fixed = false; + /** + * Sets and reflects the currently expanded accordion 0-based indexes. + * Use commas to separate multiple indexes. + * ```html + * + * ... + * + * ``` + */ + @property({ + attribute: 'expanded-index', + converter: NumberListConverter, + }) + get expandedIndex() { + return this.#expandedIndex; + } + + set expandedIndex(value) { + const old = this.#expandedIndex; + this.#expandedIndex = value; + if (JSON.stringify(old) !== JSON.stringify(value)) { + this.requestUpdate('expandedIndex', old); + this.collapseAll().then(async () => { + for (const i of this.expandedIndex) { + await this.expand(i); + } + }); + } + } + + #logger = new Logger(this); + + // actually is read in #init, by the `||=` operator + // eslint-disable-next-line no-unused-private-class-members + #initialized = false; + + #mo = new MutationObserver(() => this.#init()); + + #headerIndex = new RovingTabindexController(this, { + getItems: () => this.headers, + }); + + #expandedIndex: number[] = []; + + protected expandedSets = new Set(); + + get #activeHeader() { + const { headers } = this; + const index = headers.findIndex(header => header.matches(':focus,:focus-within')); + return index > -1 ? headers.at(index) : undefined; + } + + get headers() { + return this.#allHeaders(); + } + + get panels() { + return this.#allPanels(); + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('change', this.#onChange as EventListener); + this.#mo.observe(this, { childList: true }); + this.#init(); + } + + render() { + return html` + + `; + } + async firstUpdated() { - let index: number | null = null; - if (this.single) { - const allHeaders = [...this.querySelectorAll('pf-accordion-header')]; - const lastExpanded = allHeaders.filter(x => x.hasAttribute('expanded')).pop(); - if (lastExpanded) { - index = allHeaders.indexOf(lastExpanded); + let lastExpandedIndex: number; + const { headers, single } = this; + const lastExpanded = headers.filter(x => x.hasAttribute('expanded')).pop(); + if (lastExpanded) { + lastExpandedIndex = headers.indexOf(lastExpanded); + } + headers.forEach((header, index) => { + if (header.expanded && (!single || index === lastExpandedIndex)) { + this.#expandHeader(header, index); + const panel = this.#panelForHeader(header); + if (panel) { + this.#expandPanel(panel); + } } + }); + } + + protected override async getUpdateComplete(): Promise { + const c = await super.getUpdateComplete(); + const results = await Promise.all([ + ...this.#allHeaders().map(x => x.updateComplete), + ...this.#allPanels().map(x => x.updateComplete), + ]); + return c && results.every(Boolean); + } + + /** + * Initialize the accordion by connecting headers and panels + * with aria controls and labels; set up the default disclosure + * state if not set by the author; and check the URL for default + * open + */ + async #init() { + this.#initialized ||= !!await this.updateComplete; + // Event listener to the accordion header after the accordion has been initialized to add the roving tabindex + this.addEventListener('focusin', this.#updateActiveHeader); + this.updateAccessibility(); + } + + #updateActiveHeader() { + if (this.#activeHeader !== this.#headerIndex.activeItem) { + this.#headerIndex.setActiveItem(this.#activeHeader); } - await super.firstUpdated(); - if (index !== null) { - this.headers.forEach((_, i) => { - this.headers.at(i)?.toggleAttribute('expanded', i === index); - this.panels.at(i)?.toggleAttribute('expanded', i === index); - }); + } + + #panelForHeader(header: PfAccordionHeader) { + const next = header.nextElementSibling; + if (!(next instanceof PfAccordionPanel)) { + return void this.#logger.error('Sibling element to a header needs to be a panel'); + } else { + return next; } } - override async expand(index: number, parentAccordion?: BaseAccordion) { - if (index === -1) { + #expandHeader(header: PfAccordionHeader, index = this.#getIndex(header)) { + // If this index is not already listed in the expandedSets array, add it + this.expandedSets.add(index); + this.#expandedIndex = [...this.expandedSets as Set]; + header.expanded = true; + } + + #expandPanel(panel: PfAccordionPanel) { + panel.expanded = true; + panel.hidden = false; + } + + async #collapseHeader(header: PfAccordionHeader, index = this.#getIndex(header)) { + if (!this.expandedSets) { + await this.updateComplete; + } + this.expandedSets.delete(index); + header.expanded = false; + await header.updateComplete; + } + + async #collapsePanel(panel: PfAccordionPanel) { + await panel.updateComplete; + if (!panel.expanded) { return; } - const allHeaders: BaseAccordionHeader[] = this.headers; + panel.expanded = false; + panel.hidden = true; + } + + #onChange(event: PfAccordionHeaderChangeEvent) { + if (event instanceof PfAccordionHeaderChangeEvent && event.accordion === this) { + const index = this.#getIndex(event.target); + if (event.expanded) { + this.expand(index); + } else { + this.collapse(index); + } + event.stopPropagation(); + } + } + + #allHeaders(accordion: PfAccordion = this): PfAccordionHeader[] { + return Array.from(accordion.children ?? []).filter((x): x is PfAccordionHeader => + x instanceof PfAccordionHeader); + } + + #allPanels(accordion: PfAccordion = this): PfAccordionPanel[] { + return Array.from(accordion.children ?? []).filter((x): x is PfAccordionPanel => + x instanceof PfAccordionPanel); + } + + #getIndex(el: Element | null) { + if (el instanceof PfAccordionHeader) { + return this.headers.findIndex(header => header.id === el.id); + } + + if (el instanceof PfAccordionPanel) { + return this.panels.findIndex(panel => panel.id === el.id); + } + + this.#logger.warn('The #getIndex method expects to receive a header or panel element.'); + return -1; + } + + public updateAccessibility() { + this.#headerIndex.updateItems(); + const { headers } = this; + + // For each header in the accordion, attach the aria connections + headers.forEach(header => { + const panel = this.#panelForHeader(header); + if (panel) { + header.setAttribute('aria-controls', panel.id); + panel.setAttribute('aria-labelledby', header.id); + panel.hidden = !panel.expanded; + } + }); + } + + /** + * Accepts a 0-based index value (integer) for the set of accordion items to expand. + * Accepts an optional parent accordion to search for headers and panels. + * @param index index (0-based) of the panel to expand + */ + public async expand(index: number) { + if (index === -1) { + return; + } // Get all the headers and capture the item by index value if (this.single) { await Promise.all([ - ...allHeaders.map((header, index) => header.expanded && this.collapse(index)), + ...this.headers.map((header, index) => header.expanded && this.collapse(index)), ]); } - await super.expand(index, parentAccordion); + const header = this.headers[index]; + if (!header) { + return; + } + + const panel = this.#panelForHeader(header); + if (!panel) { + return; + } + + // If the header and panel exist, open both + this.#expandHeader(header, index), + this.#expandPanel(panel), + + header.focus(); + + this.dispatchEvent(new PfAccordionExpandEvent(header, panel)); + + await this.updateComplete; + } + + /** + * Accepts a 0-based index value (integer) for the set of accordion items to collapse. + * @param index index (0-based) of the panel to collapse + */ + public async collapse(index: number) { + const header = this.headers.at(index); + const panel = this.panels.at(index); + + if (!header || !panel) { + return; + } + + this.#collapseHeader(header); + this.#collapsePanel(panel); + + this.dispatchEvent(new PfAccordionCollapseEvent(header, panel)); + await this.updateComplete; + } + + /** + * Accepts a 0-based index value (integer) for the set of accordion items to expand or collapse. + * @param index index (0-based) of the panel to toggle + */ + public async toggle(index: number) { + const { headers } = this; + const header = headers[index]; + + if (!header.expanded) { + await this.expand(index); + } else { + await this.collapse(index); + } + } + + /** + * Expands all accordion items. + */ + public async expandAll() { + this.headers.forEach(header => this.#expandHeader(header)); + this.panels.forEach(panel => this.#expandPanel(panel)); + await this.updateComplete; + } + + + /** + * Collapses all accordion items. + */ + public async collapseAll() { + this.headers.forEach(header => this.#collapseHeader(header)); + this.panels.forEach(panel => this.#collapsePanel(panel)); + await this.updateComplete; } } diff --git a/elements/pf-accordion/test/pf-accordion.spec.ts b/elements/pf-accordion/test/pf-accordion.spec.ts index 5bb00395e6..032878cf7a 100644 --- a/elements/pf-accordion/test/pf-accordion.spec.ts +++ b/elements/pf-accordion/test/pf-accordion.spec.ts @@ -2,6 +2,9 @@ import { expect, html, aTimeout, nextFrame } from '@open-wc/testing'; import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; import { sendKeys } from '@web/test-runner-commands'; +import { allUpdates, clickElementAtCenter } from '@patternfly/pfe-tools/test/utils.js'; +import { a11ySnapshot, querySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; + // Import the element we're testing. import { PfAccordion, PfAccordionPanel, PfAccordionHeader } from '@patternfly/elements/pf-accordion/pf-accordion.js'; import { PfSwitch } from '@patternfly/elements/pf-switch/pf-switch.js'; @@ -9,7 +12,6 @@ import { PfSwitch } from '@patternfly/elements/pf-switch/pf-switch.js'; import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; import '@patternfly/pfe-tools/test/stub-logger.js'; -import { allUpdates } from '@patternfly/pfe-tools/test/utils.js'; describe('', function() { let element: PfAccordion; @@ -21,12 +23,12 @@ describe('', function() { let secondPanel: PfAccordionPanel; async function clickFirstHeader() { - header.click(); + await clickElementAtCenter(header); await allUpdates(element); } async function clickSecondHeader() { - secondHeader.click(); + await clickElementAtCenter(secondHeader); await allUpdates(element); } @@ -126,18 +128,24 @@ describe('', function() { describe('clicking the first header', function() { beforeEach(clickFirstHeader); - it('expands first pair', function() { - expect(header.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded')).to.equal('true'); + it('expands first pair', async function() { + const snapshot = await a11ySnapshot(); + const expanded = snapshot?.children?.find(x => x.expanded); + const focused = snapshot?.children?.find(x => x.focused); + expect(expanded?.name).to.equal(header.textContent?.trim()); expect(header.expanded).to.be.true; expect(panel.hasAttribute('expanded')).to.be.true; expect(panel.expanded).to.be.true; + expect(expanded).to.equal(focused); }); describe('then clicking first header again', function() { beforeEach(clickFirstHeader); - it('collapses first pair', function() { - expect(header.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded')).to.equal('false'); + it('collapses first pair', async function() { + const snapshot = await a11ySnapshot(); + const expanded = snapshot?.children?.find(x => x.expanded); + expect(expanded).to.not.be.ok; expect(header.expanded).to.be.false; expect(panel.hasAttribute('expanded')).to.be.false; expect(panel.expanded).to.be.false; @@ -335,7 +343,7 @@ describe('', function() { describe('with all panels open', function() { beforeEach(async function() { for (const header of element.querySelectorAll('pf-accordion-header')) { - header.click(); + await clickElementAtCenter(header); } await nextFrame(); }); @@ -1019,27 +1027,52 @@ describe('', function() { beforeEach(async function() { element = await createFixture(html` - + + top-header-1 + + top-panel-1 - - + + nest-1-header-1 + + + nest-1-panel-1 + - + + top-header-2 + + top-panel-2 - - - - - - + + nest-2-header-1 + + + nest-2-header-1 + + + nest-2-header-2 + + + nest-2-panel-2 + + + nest-2-header-3 + + + nest-2-panel-3 + - - - + + top-header-3 + + + top-panel-3 + `); topLevelHeaderOne = document.getElementById('header-1') as PfAccordionHeader; @@ -1062,47 +1095,44 @@ describe('', function() { describe('clicking the first top-level heading', function() { beforeEach(async function() { - topLevelHeaderOne.click(); + await clickElementAtCenter(topLevelHeaderOne); await allUpdates(element); }); describe('then clicking the second top-level heading', function() { beforeEach(async function() { - topLevelHeaderTwo.click(); + await clickElementAtCenter(topLevelHeaderTwo); await allUpdates(element); }); describe('then clicking the first nested heading', function() { beforeEach(async function() { - nestedHeaderOne.click(); + await clickElementAtCenter(nestedHeaderOne); await allUpdates(element); }); describe('then clicking the second nested heading', function() { beforeEach(async function() { - nestedHeaderTwo.click(); + await clickElementAtCenter(nestedHeaderTwo); await allUpdates(element); }); - it('expands the first top-level pair', function() { - expect(topLevelHeaderOne.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded'), 'top level header 1 button aria-expanded attr').to.equal('true'); - expect(topLevelHeaderOne.expanded, 'top level header 1 expanded DOM property').to.be.true; - expect(topLevelPanelOne.hasAttribute('expanded'), 'top level panel 1 expanded attr').to.be.true; - expect(topLevelPanelOne.expanded, 'top level panel 1 DOM property').to.be.true; + it('expands the first top-level pair', async function() { + const snapshot = await a11ySnapshot(); + const expanded = snapshot?.children?.find(x => x.expanded); + expect(expanded?.name).to.equal(topLevelHeaderOne.textContent?.trim()); + expect(topLevelHeaderOne.expanded).to.be.true; + expect(topLevelPanelOne.hasAttribute('expanded')).to.be.true; + expect(topLevelPanelOne.expanded).to.be.true; }); - it('collapses the second top-level pair', function() { - expect(topLevelHeaderTwo.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded'), 'top level header 2 button aria-expanded attr').to.equal('true'); - expect(topLevelHeaderTwo.expanded, 'top level header 2 expanded DOM property').to.be.true; - expect(topLevelPanelTwo.hasAttribute('expanded'), 'top level panel 2 expanded attr').to.be.true; - expect(topLevelPanelTwo.expanded, 'top level panel 2 expanded DOM property').to.be.true; + it('collapses the second top-level pair', async function() { + const snapshot = await a11ySnapshot(); + const header2 = querySnapshot(snapshot, { name: 'top-header-2' }); + expect(header2).to.have.property('expanded', true); }); - it('collapses the first nested pair', function() { - expect(nestedHeaderOne.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded'), 'nested header 1 button aria-expanded attr').to.equal('false'); - expect(nestedHeaderOne.expanded, 'nested header 1 expanded DOM property').to.be.false; - expect(nestedPanelOne.hasAttribute('expanded'), 'nested panel 1 expanded attr').to.be.false; - expect(nestedPanelOne.expanded, 'nested panel 1 expanded DOM property').to.be.false; + it('collapses the first nested pair', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { name: 'nest-1-header-1' })).to.not.have.property('expanded'); }); - it('collapses the second nested pair', function() { - expect(nestedHeaderTwo.shadowRoot!.querySelector('button')?.getAttribute('aria-expanded'), 'nested header 2 button aria-expanded attr').to.equal('true'); - expect(nestedHeaderTwo.expanded, 'nested header 2 expanded DOM property').to.be.true; - expect(nestedPanelTwo.hasAttribute('expanded'), 'nested panel 2 expanded attr').to.be.true; - expect(nestedPanelTwo.expanded, 'nested panel 2 expanded DOM property').to.be.true; + it('collapses the second nested pair', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshot(snapshot, { name: 'nest-2-header-1' })).to.not.have.property('expanded'); }); }); }); @@ -1122,9 +1152,10 @@ describe('', function() { describe('with all panels open', function() { beforeEach(async function() { - for (const header of element.querySelectorAll('pf-accordion-header')) { - header.click(); - } + await Promise.all(Array.from( + document.querySelectorAll('pf-accordion'), + accordion => accordion.expandAll(), + )); await nextFrame(); }); it('removes hidden attribute from all panels', function() { @@ -1278,7 +1309,7 @@ describe('', function() { describe('with all panels open', function() { beforeEach(async function() { for (const header of multipleAccordionElements.querySelectorAll('pf-accordion-header')) { - header.click(); + await clickElementAtCenter(header); } await nextFrame(); }); @@ -1351,7 +1382,7 @@ describe('', function() { describe('clicking the checkbox', function() { beforeEach(async function() { - checkbox.click(); + await clickElementAtCenter(checkbox); await element.updateComplete; }); it('does not collapse the panel', function() { @@ -1362,7 +1393,7 @@ describe('', function() { describe('clicking the switch', function() { beforeEach(async function() { const { checked } = pfswitch; - pfswitch.click(); + await clickElementAtCenter(pfswitch); await element.updateComplete; await pfswitch.updateComplete; expect(pfswitch.checked).to.not.equal(checked); diff --git a/elements/pf-back-to-top/pf-back-to-top.css b/elements/pf-back-to-top/pf-back-to-top.css index 793e87e68a..9127535ef5 100644 --- a/elements/pf-back-to-top/pf-back-to-top.css +++ b/elements/pf-back-to-top/pf-back-to-top.css @@ -29,6 +29,16 @@ a { gap: var(--pf-c-button__icon--m-end--MarginLeft, var(--pf-global--spacer--xs, 0.25rem)); } +a:hover { + --pf-c-button--m-primary--Color: var(--pf-c-button--m-primary--hover--Color, var(--pf-global--Color--light-100, #fff)); + --pf-c-button--m-primary--BackgroundColor: var(--pf-c-button--m-primary--hover--BackgroundColor, var(--pf-global--primary-color--200, #004080)); +} + +a:focus { + --pf-c-button--m-primary--Color: var(--pf-c-button--m-primary--hover--Color, var(--pf-global--Color--light-100,#fff)); + --pf-c-button--m-primary--BackgroundColor: var(--pf-c-button--m-primary--hover--BackgroundColor, var(--pf-global--primary-color--200, #004080)); +} + [part="trigger"][hidden] { display: none; } diff --git a/elements/pf-icon/README.md b/elements/pf-icon/README.md index 280462273e..f739929014 100644 --- a/elements/pf-icon/README.md +++ b/elements/pf-icon/README.md @@ -49,6 +49,7 @@ Icon comes with three built-in icon sets: 1. `fas`: Font Awesome Free Solid icons (the default set) 1. `far`: Font Awesome Free Regular icons +1. `fab`: Font Awesome Free Bold icons 1. `patternfly`: PatternFly icons Use the `set` attribute to pick an alternative icon set. @@ -61,19 +62,31 @@ Use the `set` attribute to pick an alternative icon set. It is possible to add custom icon sets or override the default sets. Icon sets are defined in detail in [the docs][icon-sets]. -### Bundling +### Bundling and custom loading behaviour -When bundling PfIcon with other modules, the default icon imports will be -relative to the bundle, not the source file, so be sure to either register all -the icon sets you'll need, or override the default getter. +When bundling `` with other modules (e.g. using webpack, rollup, +esbuild, vite, or similar tools), icon imports will be code-split into chunks, +as they are imported from the `@patternfly/icons` package. Ensure that your +bundler is configured to permit dynamic imports, or mark the `@patternfly/icons` +package as "external" and apply an [import map][importmap] to your page instead. +If you would like to +customize the loading behaviour, override the `PfIcon.resolve()` static method. +This methods takes two arguments: the icon set (a string) and the icon name +(a string), and returns a promise of the icon contents, which is a DOM node, or +[anything else that lit-html can render][renderable]. ```js -// Workaround for bundled pf-icon: make icon imports absolute, instead of -relative to the bundle import { PfIcon } from '/pfe.min.js'; -PfIcon.getIconUrl = (set, icon) => - new URL(`/assets/icons/${set}/${icon}.js`, import.meta.url); - // default: new URL(`./icons/${set}/${icon}.js`, import.meta.url); +PfIcon.resolve = async function(set, icon) { + try { + const { default: content } = await import(`/assets/icons/${set}/${icon}.js`); + if (content instanceof Node) { + return content.cloneNode(true); + } + } catch (e) { + return ''; + } +} ``` ## Loading @@ -84,3 +97,5 @@ see the [docs][docs] for more info. [docs]: https://patternflyelements.org/components/icon/ [icon-sets]: https://patternflyelements.org/components/icon/#icon-sets +[renderable]: https://lit.dev/docs/components/rendering/#renderable-values +[importmap]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap diff --git a/elements/pf-icon/demo/custom-icon-sets.html b/elements/pf-icon/demo/custom-icon-sets.html index ef9ba98e14..9e62817bd6 100644 --- a/elements/pf-icon/demo/custom-icon-sets.html +++ b/elements/pf-icon/demo/custom-icon-sets.html @@ -15,8 +15,9 @@

Custom Icon Sets

``` ```js import { PfIcon } from '@patternfly/elements/pf-icon/pf-icon.js'; - PfIcon.addIconSet('rh', (set, icon) => - new URL(`./icons/${set}/${icon}.js`, import.meta.url)); + PfIcon.addIconSet('rh', async (set, icon) => + import(`./icons/${set}/${icon}.js`) + .then(mod => mod.default)); ``` @@ -25,8 +26,9 @@

Custom Icon Sets

+ diff --git a/tools/create-element/templates/element/demo/element.js b/tools/create-element/templates/element/demo/element.js deleted file mode 100644 index 8b4e7a999b..0000000000 --- a/tools/create-element/templates/element/demo/element.js +++ /dev/null @@ -1 +0,0 @@ -import '<%= importSpecifier %>'; diff --git a/tools/pfe-tools/CHANGELOG.md b/tools/pfe-tools/CHANGELOG.md index fa03cf86de..67869dd927 100644 --- a/tools/pfe-tools/CHANGELOG.md +++ b/tools/pfe-tools/CHANGELOG.md @@ -1,5 +1,18 @@ # @patternfly/pfe-tools +## 2.0.3 + +### Patch Changes + +- aca8409: **React**: ensure that only classes which are exported get wrapped + +## 2.0.2 + +### Patch Changes + +- c57c5dd: Dev Server: redirect demo links to css lightdom subresources +- 9995136: **React**: corrected syntax error in some generated modules + ## 2.0.1 ### Patch Changes diff --git a/tools/pfe-tools/dev-server/config.ts b/tools/pfe-tools/dev-server/config.ts index f9981f08e5..7c59b1ca61 100644 --- a/tools/pfe-tools/dev-server/config.ts +++ b/tools/pfe-tools/dev-server/config.ts @@ -1,6 +1,6 @@ import type { Plugin } from '@web/dev-server-core'; import type { DevServerConfig } from '@web/dev-server'; -import type { Context, Next } from 'koa'; +import type { Middleware, Context, Next } from 'koa'; import { readdir, stat } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; @@ -80,6 +80,18 @@ async function cacheBusterMiddleware(ctx: Context, next: Next) { } } +function liveReloadTsChangesMiddleware( + config: ReturnType, +): Middleware { + return function(ctx, next) { + if (!ctx.path.includes('node_modules') && ctx.path.match(new RegExp(`/^${config?.elementsDir}\\/.*.js/`))) { + ctx.redirect(ctx.path.replace('.js', '.ts')); + } else { + return next(); + } + }; +} + /** * Creates a default config for PFE's dev server. */ @@ -96,6 +108,7 @@ export function pfeDevServerConfig(options?: PfeDevServerConfigOptions): DevServ middleware: [ cors, cacheBusterMiddleware, + liveReloadTsChangesMiddleware(config), ...config?.middleware ?? [], ], diff --git a/tools/pfe-tools/dev-server/plugins/pfe-dev-server.ts b/tools/pfe-tools/dev-server/plugins/pfe-dev-server.ts index 63ba2ee34e..08643064f5 100644 --- a/tools/pfe-tools/dev-server/plugins/pfe-dev-server.ts +++ b/tools/pfe-tools/dev-server/plugins/pfe-dev-server.ts @@ -104,11 +104,11 @@ function getRouter(options: PfeDevServerInternalConfig) { // Redirect `components/jazz-hands/*-lightdom.css` to `elements/pf-jazz-hands/*-lightdom.css` // NOTE: don't put subresources in /demo called `*-lightdom.css` , or this will break - .get(`/${componentSubpath}/:element/(demo/)?:fileName-lightdom.css`, async (ctx, next) => { + .get(`/${componentSubpath}/:element/(demo/)?:fileName.css`, async (ctx, next) => { const { element, fileName } = ctx.params; - if (!element.startsWith(tagPrefix)) { + if (!element.startsWith(tagPrefix) && fileName.includes('lightdom')) { const prefixedElement = deslugify(element); - ctx.redirect(`/${elementsDir}/${prefixedElement}/${fileName}-lightdom.css`); + ctx.redirect(`/${elementsDir}/${prefixedElement}/${fileName}.css`); } else { return next(); } diff --git a/tools/pfe-tools/package.json b/tools/pfe-tools/package.json index d19a20901a..4fe256edd4 100644 --- a/tools/pfe-tools/package.json +++ b/tools/pfe-tools/package.json @@ -1,6 +1,6 @@ { "name": "@patternfly/pfe-tools", - "version": "2.0.1", + "version": "2.0.3", "type": "module", "description": "Development and build tools for PatternFly Elements and related projects", "keywords": [ @@ -41,7 +41,8 @@ "./test/render-to-string.js": "./test/render-to-string.js", "./test/stub-logger.js": "./test/stub-logger.js", "./test/utils.js": "./test/utils.js", - "./typescript/transformers/css-imports.cjs": "./typescript/transformers/css-imports.cjs" + "./typescript/transformers/css-imports.cjs": "./typescript/transformers/css-imports.cjs", + "./typescript/transformers/static-version.cjs": "./typescript/transformers/static-version.cjs" }, "contributors": [ "Kyle Buchanan (https://github.com/kylebuch8)", diff --git a/tools/pfe-tools/react/generate-wrappers.ts b/tools/pfe-tools/react/generate-wrappers.ts index a402579ffe..86ac859847 100644 --- a/tools/pfe-tools/react/generate-wrappers.ts +++ b/tools/pfe-tools/react/generate-wrappers.ts @@ -22,6 +22,12 @@ function isCustomElementDeclaration( return !!(declaration as CEM.CustomElementDeclaration).customElement; } +function isExported(exports: CEM.Export[] | undefined) { + return function(declaration: CEM.Declaration): boolean { + return !!exports?.some(exp => exp.kind === 'js' && exp.declaration.name === declaration.name); + }; +} + /** Remove a prefix from a class name */ function getDeprefixedClassName(className: string, prefix: string) { const [fst, ...tail] = className.replace(prefix, ''); @@ -80,10 +86,10 @@ function genJavascriptModule(module: CEM.Module, pkgName: string, data: ReactWra return javascript`// ${module.path} import { createComponent } from '@lit/react'; import react from 'react';${data.map(x => javascript` -import { ${x.Class} as elementClass } from '${pkgName}/${module.path}';`)}${data.map(x => javascript` +import { ${x.Class} } from '${pkgName}/${module.path}';`).join('')}${data.map(x => javascript` export const ${x.reactComponentName} = createComponent({ tagName: '${x.tagName}', - elementClass, + elementClass: ${x.Class}, react, events: ${x.eventsMap}, });`).join('\n')} @@ -93,8 +99,8 @@ export const ${x.reactComponentName} = createComponent({ function genTypescriptModule(module: CEM.Module, pkgName: string, data: ReactWrapperData[]) { return typescript`// ${module.path} import type { ReactWebComponent } from '@lit/react';${data.map(x => typescript` -import type { ${x.Class} } from '${pkgName}/${module.path}';`)}${data.map(x => typescript` -export const ${x.reactComponentName}: ReactWebComponent<${x.Class}, ${x.eventsInterface}>;`)} +import type { ${x.Class} } from '${pkgName}/${module.path}';`).join('')}${data.map(x => typescript` +export const ${x.reactComponentName}: ReactWebComponent<${x.Class}, ${x.eventsInterface}>;`).join('\n')} `; } @@ -106,6 +112,7 @@ function genWrapperModules( ) { const data: ReactWrapperData[] = (module.declarations ?? []) .filter(isCustomElementDeclaration) + .filter(isExported(module.exports)) .map(getReactWrapperData(module, classPrefix, elPrefix)); const js = genJavascriptModule(module, pkgName, data); const ts = genTypescriptModule(module, pkgName, data); diff --git a/tools/pfe-tools/test/a11y-snapshot.ts b/tools/pfe-tools/test/a11y-snapshot.ts index f3d4edf173..bcb625a4b5 100644 --- a/tools/pfe-tools/test/a11y-snapshot.ts +++ b/tools/pfe-tools/test/a11y-snapshot.ts @@ -7,6 +7,7 @@ export interface A11yTreeSnapshot { checked?: boolean; disabled?: boolean; description?: string; + expanded?: boolean; focused?: boolean; haspopup?: string; level?: number; @@ -29,3 +30,38 @@ export async function a11ySnapshot( } while (!snapshot && tries < 10); return snapshot; } + +type SnapshotQuery = Partial>; + +function matches(snapshot: A11yTreeSnapshot, query: SnapshotQuery) { + return Object.entries(query).every(([key, value]) => + JSON.stringify(snapshot[key as keyof typeof snapshot]) === JSON.stringify(value)); +} + +function doQuery(snapshot: A11yTreeSnapshot, query: SnapshotQuery): A11yTreeSnapshot | null { + if (matches(snapshot, query)) { + return snapshot; + } else if (!snapshot.children) { + return null; + } else { + for (const kid of snapshot.children) { + const result = doQuery(kid, query); + if (result) { + return result; + } + } + } + return null; +} + +/** + * Deeply search an accessibility tree snapshot for an object matching your query + * @param snapshot the snapshot root to recurse through + * @param query object with properties matching the snapshot child you seek + */ +export function querySnapshot( + snapshot: A11yTreeSnapshot, + query: SnapshotQuery, +): A11yTreeSnapshot | null { + return doQuery(snapshot, query); +} diff --git a/tools/pfe-tools/typescript/transformers/css-imports.cjs b/tools/pfe-tools/typescript/transformers/css-imports.cjs index c3682fe7ad..61414c18f7 100644 --- a/tools/pfe-tools/typescript/transformers/css-imports.cjs +++ b/tools/pfe-tools/typescript/transformers/css-imports.cjs @@ -1,5 +1,4 @@ -// @ts-check -const ts = require('typescript/lib/typescript'); +const ts = require('typescript'); const fs = require('node:fs'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -7,8 +6,8 @@ const { pathToFileURL } = require('node:url'); const SEEN_SOURCES = new WeakSet(); /** - * @param {import('typescript').CoreTransformationContext} ctx - * @param {import('typescript').SourceFile} sourceFile + * @param {ts.CoreTransformationContext} ctx + * @param {ts.SourceFile} sourceFile */ function createLitCssImportStatement(ctx, sourceFile) { if (SEEN_SOURCES.has(sourceFile)) { @@ -45,8 +44,8 @@ function createLitCssImportStatement(ctx, sourceFile) { } /** - * @param {import('typescript').CoreTransformationContext} ctx - * @param {string} stylesheet + * @param {ts.CoreTransformationContext} ctx + * @param {ts.SourceFile} sourceFile * @param {string} [name] */ function createLitCssTaggedTemplateLiteral(ctx, stylesheet, name) { @@ -87,18 +86,14 @@ function minifyCss(stylesheet, filePath) { } } -/** - * @param node - * @param{import('typescript').ImportDeclaration} node - */ +/** @param {ts.ImportDeclaration} node */ function getImportSpecifier(node) { return node.moduleSpecifier.getText().replace(/^'(.*)'$/, '$1'); } /** - * @param node - * @param{import('typescript').Node} node - * @returns {node is import('typescript').ImportDeclaration} + * @param {ts.Node} node + * @returns {node is ts.ImportDeclaration} */ function isCssImportNode(node) { if (ts.isImportDeclaration(node) && !node.importClause?.isTypeOnly) { @@ -115,11 +110,7 @@ const cssImportSpecImporterMap = new Map(); /** map from (abspath to import spec) to (abspaths to manually written transformed module) */ const cssImportFakeEmitMap = new Map(); -// abspath to file -/** - * @param node - * @param{import('typescript').ImportDeclaration} node - */ +/** @param {ts.ImportDeclaration} node */ function getImportAbsPathOrBareSpec(node) { const specifier = getImportSpecifier(node); if (!specifier.startsWith('.')) { @@ -131,9 +122,7 @@ function getImportAbsPathOrBareSpec(node) { } } -/** - * @param {import('typescript').SourceFile} sourceFile - */ +/** @param {ts.SourceFile} sourceFile */ function cacheCssImportSpecsAbsolute(sourceFile) { sourceFile.forEachChild(node => { if (isCssImportNode(node)) { @@ -151,13 +140,16 @@ function cacheCssImportSpecsAbsolute(sourceFile) { * If the inline option is set, remove the import specifier and print the css * object in place, except if that module is imported elsewhere in the project, * in which case leave a `.css.js` import - * @param {import('typescript').Program} program - * @param root0 - * @param root0.inline - * @param root0.minify - * @returns {import('typescript').TransformerFactory} + * @param {ts.Program} program + * @param opts + * @param {boolean} opts.inline + * @param {boolean} opts.minify + * @returns {ts.TransformerFactory} */ -module.exports = function(program, { inline = false, minify = false } = {}) { +module.exports = function(program, { + inline = false, + minify = false, +} = {}) { return ctx => { for (const sourceFileName of program.getRootFileNames()) { const sourceFile = program.getSourceFile(sourceFileName); @@ -166,10 +158,7 @@ module.exports = function(program, { inline = false, minify = false } = {}) { } } - /** - * @param node - * @param{import('typescript').Node} node - */ + /** @param {ts.Node} node */ function rewriteOrInlineVisitor(node) { if (isCssImportNode(node)) { const { fileName } = node.getSourceFile(); @@ -210,12 +199,12 @@ module.exports = function(program, { inline = false, minify = false } = {}) { return sourceFile => { const children = sourceFile.getChildren(); const litImportBindings = - /** @type{import('typescript').ImportDeclaration}*/(children.find(x => + (children.find(/** @returns {x is ts.ImportDeclaration} */x => !ts.isTypeOnlyImportOrExportDeclaration(x) - && !ts.isNamespaceImport(x) - && ts.isImportDeclaration(x) - && x.moduleSpecifier.getText() === 'lit' - && x.importClause?.namedBindings + && !ts.isNamespaceImport(x) + && ts.isImportDeclaration(x) + && x.moduleSpecifier.getText() === 'lit' + && !!x.importClause?.namedBindings ))?.importClause?.namedBindings; const hasStyleImports = children.find(x => @@ -223,8 +212,8 @@ module.exports = function(program, { inline = false, minify = false } = {}) { if (hasStyleImports) { if (litImportBindings - && ts.isNamedImports(litImportBindings) - && !litImportBindings.elements?.some(x => x.getText() === 'css')) { + && ts.isNamedImports(litImportBindings) + && !litImportBindings.elements?.some(x => x.getText() === 'css')) { ctx.factory.updateNamedImports( litImportBindings, [ diff --git a/tools/pfe-tools/typescript/transformers/static-version.cjs b/tools/pfe-tools/typescript/transformers/static-version.cjs new file mode 100644 index 0000000000..3f221417d5 --- /dev/null +++ b/tools/pfe-tools/typescript/transformers/static-version.cjs @@ -0,0 +1,84 @@ +const ts = require('typescript'); +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * @param {ts.ModifierLike} mod + * @returns {mod is ts.ExportKeyword} + */ +const isExportKeyword = mod => + mod.kind === ts.SyntaxKind.ExportKeyword; + +/** + * @param {ts.ModifierLike} mod + * @returns {mod is ts.Decorator} + */ +const isCustomElementDecorator = mod => + ts.isDecorator(mod) + && ts.isCallExpression(mod.expression) + && ts.isIdentifier(mod.expression.expression) + && mod.expression.expression.escapedText === 'customElement'; + +/** + * @param {ts.Node} node + * @returns {node is ts.ClassDeclaration} + */ +const isExportCustomElementClass = node => + ts.isClassDeclaration(node) + && !!node.modifiers?.some(isExportKeyword) + && !!node.modifiers?.some(isCustomElementDecorator); + +/** @param {string} dir */ +function findPackageDir(dir) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + const parentDir = path.resolve(dir, '..'); + if (dir === parentDir) { + return null; + } + return findPackageDir(parentDir); +} + +/** @param {string} filePath */ +function getNearestPackageJson(filePath) { + const parentDir = path.dirname(filePath); + const packageDir = findPackageDir(parentDir); + if (packageDir) { + const filePath = path.normalize(`${packageDir}/package.json`); + return require(filePath); + } else { + return null; + } +} + +/** @returns {ts.TransformerFactory} */ +module.exports = () => ctx => { + return sourceFile => ts.visitEachChild( + sourceFile, + function addVersionVisitor(node) { + if (isExportCustomElementClass(node)) { + const { fileName } = node.getSourceFile(); + const packageJson = getNearestPackageJson(fileName); + if (packageJson?.version) { + return ctx.factory.createClassDeclaration( + node.modifiers, + node.name, + node.typeParameters, + node.heritageClauses, + node.members.concat(ctx.factory.createPropertyDeclaration( + [ctx.factory.createModifier(ts.SyntaxKind.StaticKeyword)], + 'version', + undefined, + undefined, + ctx.factory.createStringLiteral(packageJson.version) + )) + ); + } + } + return node; + }, + ctx + ); +}; + diff --git a/tsconfig.settings.json b/tsconfig.settings.json index 5a7bbf2bae..ecd77cb42f 100644 --- a/tsconfig.settings.json +++ b/tsconfig.settings.json @@ -40,6 +40,9 @@ "transform": "@patternfly/pfe-tools/typescript/transformers/css-imports.cjs", "inline": true }, + { + "transform": "@patternfly/pfe-tools/typescript/transformers/static-version.cjs" + }, { "name": "typescript-lit-html-plugin" },