diff --git a/.changeset/yummy-eagles-itch.md b/.changeset/yummy-eagles-itch.md new file mode 100644 index 0000000000..2d1cc9b577 --- /dev/null +++ b/.changeset/yummy-eagles-itch.md @@ -0,0 +1,19 @@ +--- +"@patternfly/elements": minor +--- + +Added ``. + +A **label group** is a collection of labels that can be grouped by category +and used to represent one or more values assigned to a single attribute. +When the number of labels exceeds the configured limit, additional labels +are hidden under an overflow indicator. + +```html + + Filters + Security + Performance + Networking + +``` diff --git a/elements/package.json b/elements/package.json index 3daa19ca2e..be137e3b38 100644 --- a/elements/package.json +++ b/elements/package.json @@ -39,6 +39,7 @@ "./pf-jump-links/pf-jump-links-item.js": "./pf-jump-links/pf-jump-links-item.js", "./pf-jump-links/pf-jump-links-list.js": "./pf-jump-links/pf-jump-links-list.js", "./pf-jump-links/pf-jump-links.js": "./pf-jump-links/pf-jump-links.js", + "./pf-label-group/pf-label-group.js": "./pf-label-group/pf-label-group.js", "./pf-label/pf-label.js": "./pf-label/pf-label.js", "./pf-select/pf-select.js": "./pf-select/pf-select.js", "./pf-select/pf-listbox.js": "./pf-select/pf-listbox.js", diff --git a/elements/pf-label-group/README.md b/elements/pf-label-group/README.md new file mode 100644 index 0000000000..f966d3d3c8 --- /dev/null +++ b/elements/pf-label-group/README.md @@ -0,0 +1,46 @@ +# Label Group +A label group is a collection of labels that can be grouped by category and +used to represent one or more values assigned to a single attribute. When +the number of labels exceeds the configured limit, additional labels are +hidden under an overflow indicator. + +Read more about Label Group in the [PatternFly Elements Label Group documentation](https://patternflyelements.org/components/label-group) + +## Installation + +Load `` via CDN: + +```html + +``` + +Or, if you are using [NPM](https://npm.im), install it + +```bash +npm install @patternfly/elements +``` + +Then once installed, import it to your application: + +```js +import '@patternfly/elements/pf-label-group/pf-label-group.js'; +``` + +## Usage + +```html + + Security + Performance + Networking + +``` + +With a category: +```html + + Filters + Security + Performance + +``` diff --git a/elements/pf-label-group/demo/index.html b/elements/pf-label-group/demo/index.html new file mode 100644 index 0000000000..fe2b946d5c --- /dev/null +++ b/elements/pf-label-group/demo/index.html @@ -0,0 +1,9 @@ + + Blue + Green + Orange + + + diff --git a/elements/pf-label-group/demo/label-group-closeable.html b/elements/pf-label-group/demo/label-group-closeable.html new file mode 100644 index 0000000000..bc8be03463 --- /dev/null +++ b/elements/pf-label-group/demo/label-group-closeable.html @@ -0,0 +1,10 @@ + + Filters + Security + Performance + Networking + + + diff --git a/elements/pf-label-group/demo/label-group-vertical.html b/elements/pf-label-group/demo/label-group-vertical.html new file mode 100644 index 0000000000..ce0ae80f05 --- /dev/null +++ b/elements/pf-label-group/demo/label-group-vertical.html @@ -0,0 +1,10 @@ + + Group + Label 1 + Label 2 + Label 3 + + + diff --git a/elements/pf-label-group/demo/label-group-with-a-very-long-name.html b/elements/pf-label-group/demo/label-group-with-a-very-long-name.html new file mode 100644 index 0000000000..cce515ce8a --- /dev/null +++ b/elements/pf-label-group/demo/label-group-with-a-very-long-name.html @@ -0,0 +1,10 @@ + + Label group with a very long category name + Label 1 + Label 2 + Label 3 + + + diff --git a/elements/pf-label-group/demo/label-group-with-overflow-labels.html b/elements/pf-label-group/demo/label-group-with-overflow-labels.html new file mode 100644 index 0000000000..c7f438ef9e --- /dev/null +++ b/elements/pf-label-group/demo/label-group-with-overflow-labels.html @@ -0,0 +1,13 @@ + + Tags + Label 1 + Label 2 + Label 3 + Label 4 + Label 5 + Label 6 + + + diff --git a/elements/pf-label-group/docs/pf-label-group.md b/elements/pf-label-group/docs/pf-label-group.md new file mode 100644 index 0000000000..58771e058a --- /dev/null +++ b/elements/pf-label-group/docs/pf-label-group.md @@ -0,0 +1,17 @@ +{% renderOverview %} + +{% endrenderOverview %} + +{% band header="Usage" %}{% endband %} + +{% renderSlots %}{% endrenderSlots %} + +{% renderAttributes %}{% endrenderAttributes %} + +{% renderMethods %}{% endrenderMethods %} + +{% renderEvents %}{% endrenderEvents %} + +{% renderCssCustomProperties %}{% endrenderCssCustomProperties %} + +{% renderCssParts %}{% endrenderCssParts %} diff --git a/elements/pf-label-group/pf-label-group.css b/elements/pf-label-group/pf-label-group.css new file mode 100644 index 0000000000..a10d5fd020 --- /dev/null +++ b/elements/pf-label-group/pf-label-group.css @@ -0,0 +1,100 @@ +:host { + --pf-c-label-group__list--MarginBottom: calc(var(--pf-global--spacer--xs, 0.25rem) * -1); + --pf-c-label-group__list--MarginRight: calc(var(--pf-global--spacer--xs, 0.25rem) * -1); + --pf-c-label-group--m-category--PaddingTop: var(--pf-global--spacer--xs, 0.25rem); + --pf-c-label-group--m-category--PaddingRight: var(--pf-global--spacer--xs, 0.25rem); + --pf-c-label-group--m-category--PaddingBottom: var(--pf-global--spacer--xs, 0.25rem); + --pf-c-label-group--m-category--PaddingLeft: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-label-group--m-category--BorderRadius: var(--pf-global--BorderRadius--sm, 3px); + --pf-c-label-group--m-category--BackgroundColor: var(--pf-global--BackgroundColor--200, #f0f0f0); + --pf-c-label-group__label--MarginRight: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-label-group__label--FontSize: var(--pf-global--FontSize--sm, 0.875rem); + --pf-c-label-group__label--MaxWidth: 18ch; + --pf-c-label-group__close--MarginTop: calc(var(--pf-global--spacer--xs, 0.25rem) * -1); + --pf-c-label-group__close--MarginBottom: calc(var(--pf-global--spacer--xs, 0.25rem) * -1); + --pf-c-label-group__list-item--MarginRight: var(--pf-global--spacer--xs, 0.25rem); + --pf-c-label-group__list-item--MarginBottom: var(--pf-global--spacer--xs, 0.25rem); + display: inline-flex; + flex-wrap: wrap; + align-items: center; + min-width: 0; + max-width: 100%; + color: var(--pf-global--Color--100, #151515); +} + +[hidden], +.empty, +::slotted([hidden]), +::slotted([overflow-hidden]) { + display: none !important; +} + +#outer { + display: inline-flex; + flex: 1; + flex-wrap: wrap; + align-items: center; + min-width: 0; + column-gap: var(--pf-global--spacer--xs, 0.25rem); + border-radius: var(--pf-global--BorderRadius--sm, 3px); + padding: 0 !important; +} + +#outer.has-category { + padding: + var(--pf-c-label-group--m-category--PaddingTop) + var(--pf-c-label-group--m-category--PaddingRight) + var(--pf-c-label-group--m-category--PaddingBottom) + var(--pf-c-label-group--m-category--PaddingLeft) !important; + background-color: var(--pf-c-label-group--m-category--BackgroundColor); +} + +#labels { + margin-inline-end: var(--pf-c-label-group__list--MarginRight); + margin-block-end: var(--pf-c-label-group__list--MarginBottom); + font-family: var(--pf-global--FontFamily--sans-serif, "RedHatTextUpdated", "Overpass", overpass, helvetica, arial, sans-serif); + font-size: var(--pf-global--FontSize--sm, 14px); + font-weight: var(--pf-global--FontWeight--normal, 400); + line-height: 1.6; +} + +#labels ::slotted(pf-label) { + display: inline-flex; + min-width: 0; + margin-inline-end: var(--pf-c-label-group__list-item--MarginRight); + margin-block-end: var(--pf-c-label-group__list-item--MarginBottom); +} + +::slotted([slot="category"]) { + display: inline-block; + max-inline-size: var(--pf-c-label-group__label--MaxWidth); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + margin-inline-end: var(--pf-c-label-group__label--MarginRight); + font-size: var(--pf-c-label-group__label--FontSize); +} + +:host([orientation="vertical"]) #outer { + flex-direction: column; + align-items: flex-start; +} + +#close-button { + --pf-icon--size: 16px; + margin-block: var(--pf-c-label-group__close--MarginTop) var(--pf-c-label-group__close--MarginBottom); + inset-block-start: 0.125em; +} + +.visually-hidden { + border: 0; + clip: rect(0, 0, 0, 0); + block-size: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + inline-size: 1px; +} diff --git a/elements/pf-label-group/pf-label-group.ts b/elements/pf-label-group/pf-label-group.ts new file mode 100644 index 0000000000..9a6446c48e --- /dev/null +++ b/elements/pf-label-group/pf-label-group.ts @@ -0,0 +1,220 @@ +import { LitElement, html, isServer, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { observes } from '@patternfly/pfe-core/decorators/observes.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; + +import { PfLabel } from '../pf-label/pf-label.js'; + +import styles from './pf-label-group.css'; + +export class PfLabelGroupExpandEvent extends Event { + constructor() { + super('expand', { bubbles: true, cancelable: true }); + } +} + +export class PfLabelGroupRemoveEvent extends Event { + constructor() { + super('remove', { bubbles: true, cancelable: true }); + } +} + +/** + * `${` + * **WS** (_>= 0x_) + * `remaining` + * **WS** (_>= 0x_) + * `}` + */ +const REMAINING_RE = /\$\{\s*remaining\s*\}/g; + +/** + * A **label group** is a collection of labels that can be grouped by category + * and used to represent one or more values assigned to a single attribute. + * When the number of labels exceeds `numLabels`, additional labels will be + * hidden using an overflow label. + * + * @summary Groups multiple labels with overflow, category, and close support. + * + * @fires {PfLabelGroupExpandEvent} expand - Fires when label group is expanded to show all labels + * @fires {PfLabelGroupRemoveEvent} remove - Fires when label group is closed/removed + * + * @slot category + * Category name text for label group category. + * If this slot is populated, the label group will have category styling applied. + * @slot - `` elements. + */ +@customElement('pf-label-group') +export class PfLabelGroup extends LitElement { + static readonly styles: CSSStyleSheet[] = [styles]; + + static override readonly shadowRootOptions: ShadowRootInit = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + /** Orientation of the label group. */ + @property({ reflect: true }) orientation: 'horizontal' | 'vertical' = 'horizontal'; + + /** Accessible label for the label group when no category name is provided. */ + @property({ attribute: 'accessible-label' }) accessibleLabel = ''; + + /** Accessible label for the close button. */ + @property({ attribute: 'accessible-close-label' }) accessibleCloseLabel = 'Close'; + + /** + * Customizable "more" template string. + * Use variable `${remaining}` for the overflow label count. + */ + @property({ attribute: 'collapsed-text' }) collapsedText = '${remaining} more'; + + /** Customizable "show less" text string. */ + @property({ attribute: 'expanded-text' }) expandedText = 'show less'; + + /** Number of labels to show before overflow. */ + @property({ attribute: 'num-labels', type: Number }) numLabels = 3; + + /** Whether overflow labels are visible. */ + @property({ reflect: true, type: Boolean }) open = false; + + /** Whether the label group can be closed. */ + @property({ reflect: true, type: Boolean }) closeable = false; + + /** Label count tracked during SSR via child events. */ + _ssrLabelCount = 0; + + get #overflowLabel(): PfLabel | null { + return this.renderRoot?.querySelector?.('#overflow') ?? null; + } + + get #closeButton(): HTMLButtonElement | null { + return this.renderRoot?.querySelector?.('#close-button') ?? null; + } + + get #categorySlotted(): Node[] { + const slot = this.renderRoot + ?.querySelector?.('slot[name="category"]'); + return slot?.assignedNodes({ flatten: true }) ?? []; + } + + get #labels(): NodeListOf | PfLabel[] { + if (isServer) { + return [] as PfLabel[]; + } + return this.querySelectorAll('pf-label:not([slot]):not([overflow-label])'); + } + + get #labelCount(): number { + if (isServer) { + return this._ssrLabelCount; + } + return this.#labels.length; + } + + get #hasCategory(): boolean { + return this.#categorySlotted.length > 0; + } + + get #remaining(): number { + return this.#labelCount - this.numLabels; + } + + #tabindex = RovingTabindexController.of(this, { + getItems: () => [ + ...Array.prototype.slice.call( + this.#labels, + 0, + this.open ? this.#labels.length : Math.min(this.#labels.length, this.numLabels), + ), + this.#overflowLabel, + this.#closeButton, + ].filter(x => !!x), + }); + + constructor() { + super(); + this.addEventListener('remove', this.#onRemove); + this.addEventListener('ssr:label', () => this._ssrLabelCount++); + } + + override render(): TemplateResult<1> { + const empty = this.#labelCount <= 0; + return html` + + `; + } + + /** Updates labels when relevant properties change. */ + @observes('numLabels') + @observes('closeable') + @observes('open') + private labelsChanged(): void { + this.#updateOverflow(); + } + + #onCloseClick() { + this.dispatchEvent(new PfLabelGroupRemoveEvent()); + } + + async #onMoreClick(event: Event) { + event.stopPropagation(); + this.open = !this.open; + await this.updateComplete; + this.labelsChanged(); + if (this.#overflowLabel) { + this.#tabindex.atFocusedItemIndex = this.#tabindex.items.indexOf(this.#overflowLabel); + } + this.dispatchEvent(new PfLabelGroupExpandEvent()); + } + + #onSlotchange() { + this.requestUpdate(); + } + + #onRemove(event: Event) { + if (event instanceof PfLabelGroupRemoveEvent) { + this.remove(); + } + } + + #updateOverflow() { + this.#labels.forEach((label, i) => { + label.hidden = i >= this.numLabels && !this.open; + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-label-group': PfLabelGroup; + } +} diff --git a/elements/pf-label-group/test/pf-label-group.e2e.ts b/elements/pf-label-group/test/pf-label-group.e2e.ts new file mode 100644 index 0000000000..790889b1d6 --- /dev/null +++ b/elements/pf-label-group/test/pf-label-group.e2e.ts @@ -0,0 +1,25 @@ +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-label-group'; + +test.describe(tagName, () => { + test('snapshot', async ({ page }) => { + const componentPage = new PfeDemoPage(page, tagName); + await componentPage.navigate(); + 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-label-group/test/pf-label-group.spec.ts b/elements/pf-label-group/test/pf-label-group.spec.ts new file mode 100644 index 0000000000..5a79c3a6d1 --- /dev/null +++ b/elements/pf-label-group/test/pf-label-group.spec.ts @@ -0,0 +1,173 @@ +import { expect, html } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { a11ySnapshot, querySnapshotAll } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { PfLabelGroup } from '../pf-label-group.js'; +import { sendKeys } from '@web/test-runner-commands'; + +function press(key: string) { + return async function() { + await sendKeys({ press: key }); + }; +} + +describe('', function() { + let element: PfLabelGroup; + + describe('simply instantiating', function() { + it('imperatively instantiates', function() { + expect(document.createElement('pf-label-group')).to.be.an.instanceof(PfLabelGroup); + }); + + it('should upgrade', async function() { + element = await createFixture(html``); + const klass = customElements.get('pf-label-group'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfLabelGroup); + }); + }); + + describe('with 4 labels', function() { + beforeEach(async function() { + element = await createFixture(html` + + Label 1 + Label 2 + Label 3 + Label 4 + + `); + }); + + it('displays 3 labels and an overflow button', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshotAll(snapshot, { name: /^Label/ })).to.have.length(3); + }); + + describe('clicking overflow label', function() { + beforeEach(async function() { + const overflow = element.shadowRoot?.querySelector('#overflow') as HTMLElement; + overflow?.click(); + await element.updateComplete; + }); + it('should show all labels', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.filter(x => x.name?.startsWith('Label'))?.length).to.equal(4); + }); + it('should show collapse text', async function() { + const snapshot = await a11ySnapshot(); + expect(querySnapshotAll(snapshot, { name: 'show less' })).to.have.length(1); + }); + }); + }); + + describe('with 4 labels and `closeable` attribute', function() { + const updateComplete = () => element.updateComplete; + + beforeEach(async function() { + element = await createFixture(html` + + Label 1 + Label 2 + Label 3 + Label 4 + + `); + }); + + beforeEach(updateComplete); + + it('should have a close button', async function() { + const snapshot = await a11ySnapshot(); + const last = snapshot.children?.at(-1); + expect(last?.name).to.equal('Close'); + expect(last?.role).to.equal('button'); + }); + + describe('clicking close button', function() { + beforeEach(function() { + element.focus(); + }); + beforeEach(press('ArrowLeft')); + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('should remove element', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children).to.not.be.ok; + }); + }); + }); + + describe('with 4 labels and custom text attributes', function() { + beforeEach(async function() { + element = await createFixture(html` + + Label 1 + Label 2 + Label 3 + Label 4 + + `); + }); + + it('is accessible', async function() { + await expect(element).to.be.accessible(); + }); + }); + + describe('with 4 labels and `num-labels="2"` attribute', function() { + beforeEach(async function() { + element = await createFixture(html` + + Label 1 + Label 2 + Label 3 + Label 4 + + `); + }); + + it('only 2 labels should be visible', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.filter(x => x.name?.startsWith('Label'))?.length).to.equal(2); + }); + }); + + describe('with 4 labels and `num-labels="4"` attribute', function() { + beforeEach(async function() { + element = await createFixture(html` + + Label 1 + Label 2 + Label 3 + Label 4 + + `); + }); + + it('all 4 labels should be visible', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.filter(x => x.name?.startsWith('Label'))?.length).to.equal(4); + }); + }); + + describe('with category', function() { + beforeEach(async function() { + element = await createFixture(html` + + Group + Label 1 + Label 2 + + `); + }); + + it('should display the category text', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.some(x => x.name === 'Group')).to.be.true; + }); + }); +}); diff --git a/elements/pf-label/pf-label.ts b/elements/pf-label/pf-label.ts index 096aa6c7f7..eea234bd62 100644 --- a/elements/pf-label/pf-label.ts +++ b/elements/pf-label/pf-label.ts @@ -1,4 +1,4 @@ -import { LitElement, html, type TemplateResult } from 'lit'; +import { LitElement, html, isServer, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -71,6 +71,13 @@ export class PfLabel extends LitElement { /** Represents the state of the anonymous and icon slots */ #slots = new SlotController(this, null, 'icon'); + override connectedCallback(): void { + super.connectedCallback(); + if (isServer) { + this.dispatchEvent(new Event('ssr:label', { bubbles: true })); + } + } + override render(): TemplateResult<1> { const { compact, truncated } = this; const { variant, color, icon } = this;