diff --git a/.config/cem.yaml b/.config/cem.yaml index febc90fcec..86f5f070a6 100644 --- a/.config/cem.yaml +++ b/.config/cem.yaml @@ -1,5 +1,6 @@ sourceControlRootUrl: https://github.com/patternfly/patternfly-elements/tree/main/ generate: + output: ./elements/custom-elements.json files: - ./elements/*/*.ts - ./core/*/*.ts diff --git a/elements/package.json b/elements/package.json index fe8dda2080..11d9359e77 100644 --- a/elements/package.json +++ b/elements/package.json @@ -18,6 +18,7 @@ "./pf-v5-back-to-top/pf-v5-back-to-top.js": "./pf-v5-back-to-top/pf-v5-back-to-top.js", "./pf-v5-background-image/pf-v5-background-image.js": "./pf-v5-background-image/pf-v5-background-image.js", "./pf-v5-badge/pf-v5-badge.js": "./pf-v5-badge/pf-v5-badge.js", + "./pf-v6-badge/pf-v6-badge.js": "./pf-v6-badge/pf-v6-badge.js", "./pf-v5-banner/pf-v5-banner.js": "./pf-v5-banner/pf-v5-banner.js", "./pf-v5-button/pf-v5-button.js": "./pf-v5-button/pf-v5-button.js", "./pf-v5-card/pf-v5-card.js": "./pf-v5-card/pf-v5-card.js", diff --git a/elements/pf-v6-badge/demo/disabled.html b/elements/pf-v6-badge/demo/disabled.html new file mode 100644 index 0000000000..3e57ccf9e2 --- /dev/null +++ b/elements/pf-v6-badge/demo/disabled.html @@ -0,0 +1,12 @@ +--- +name: Disabled +description: Disabled badges indicate that the associated content or action is currently unavailable. +--- +7 +24 +240 +999+ + + diff --git a/elements/pf-v6-badge/demo/index.html b/elements/pf-v6-badge/demo/index.html new file mode 100644 index 0000000000..418d93581a --- /dev/null +++ b/elements/pf-v6-badge/demo/index.html @@ -0,0 +1,9 @@ +--- +name: Basic +description: A basic badge displays a numeric value. +--- +7 + + diff --git a/elements/pf-v6-badge/demo/read.html b/elements/pf-v6-badge/demo/read.html new file mode 100644 index 0000000000..b0239ac479 --- /dev/null +++ b/elements/pf-v6-badge/demo/read.html @@ -0,0 +1,12 @@ +--- +name: Read +description: Read badges display a numeric value with read state styling, indicating the content has been viewed. +--- +7 +24 +240 +999+ + + diff --git a/elements/pf-v6-badge/demo/threshold.html b/elements/pf-v6-badge/demo/threshold.html new file mode 100644 index 0000000000..b27ea00fca --- /dev/null +++ b/elements/pf-v6-badge/demo/threshold.html @@ -0,0 +1,10 @@ +--- +name: Threshold +description: A threshold appends a "+" when the numeric value exceeds a set maximum. +--- +400 +900 + + diff --git a/elements/pf-v6-badge/demo/unread.html b/elements/pf-v6-badge/demo/unread.html new file mode 100644 index 0000000000..7e16b908e2 --- /dev/null +++ b/elements/pf-v6-badge/demo/unread.html @@ -0,0 +1,12 @@ +--- +name: Unread +description: Unread badges use a bold, branded style to indicate content that has not yet been viewed. +--- +7 +24 +240 +999+ + + diff --git a/elements/pf-v6-badge/pf-v6-badge.css b/elements/pf-v6-badge/pf-v6-badge.css new file mode 100644 index 0000000000..e158aedfcd --- /dev/null +++ b/elements/pf-v6-badge/pf-v6-badge.css @@ -0,0 +1,58 @@ +:host { + position: relative; + display: inline-block; + min-width: var(--pf-v6-c-badge--MinWidth, + var(--pf-t--global--spacer--xl, 2rem)); + padding-inline-start: var(--pf-v6-c-badge--PaddingInlineStart, + var(--pf-t--global--spacer--sm, 0.5rem)); + padding-inline-end: var(--pf-v6-c-badge--PaddingInlineEnd, + var(--pf-t--global--spacer--sm, 0.5rem)); + font-size: var(--pf-v6-c-badge--FontSize, + var(--pf-t--global--font--size--body--sm, 0.75rem)); + font-weight: var(--pf-v6-c-badge--FontWeight, + var(--pf-t--global--font--weight--body--bold, 700)); + color: var(--pf-v6-c-badge--Color, + var(--pf-t--global--text--color--nonstatus--on-gray--default, #151515)); + text-align: center; + white-space: nowrap; + background-color: var(--pf-v6-c-badge--BackgroundColor, + var(--pf-t--global--color--nonstatus--gray--default, #f0f0f0)); + border-radius: var(--pf-v6-c-badge--BorderRadius, + var(--pf-t--global--border--radius--pill, 180em)); +} + +:host::after { + position: absolute; + inset: 0; + pointer-events: none; + content: ""; + border: var(--pf-v6-c-badge--BorderWidth, + var(--pf-t--global--border--width--regular, 1px)) solid var(--pf-v6-c-badge--BorderColor, transparent); + border-radius: inherit; +} + +:host([state="read"]) { + --pf-v6-c-badge--Color: var(--pf-v6-c-badge--m-read--Color, + var(--pf-t--global--text--color--nonstatus--on-gray--default, #151515)); + --pf-v6-c-badge--BackgroundColor: var(--pf-v6-c-badge--m-read--BackgroundColor, + var(--pf-t--global--color--nonstatus--gray--default, #f0f0f0)); + --pf-v6-c-badge--BorderColor: var(--pf-v6-c-badge--m-read--BorderColor, + var(--pf-t--global--border--color--high-contrast, #151515)); +} + +:host([state="unread"]) { + --pf-v6-c-badge--Color: var(--pf-v6-c-badge--m-unread--Color, + var(--pf-t--global--text--color--on-brand--default, #fff)); + --pf-v6-c-badge--BackgroundColor: var(--pf-v6-c-badge--m-unread--BackgroundColor, + var(--pf-t--global--color--brand--default, #06c)); +} + +:host([disabled]) { + --pf-v6-c-badge--Color: var(--pf-v6-c-badge--m-disabled--Color, + var(--pf-t--global--text--color--on-disabled, #6a6e73)); + --pf-v6-c-badge--BackgroundColor: var(--pf-v6-c-badge--m-disabled--BackgroundColor, + var(--pf-t--global--background--color--disabled--default, #d2d2d2)); + --pf-v6-c-badge--BorderColor: var(--pf-v6-c-badge--m-disabled--BorderColor, + var(--pf-t--global--border--color--disabled, #d2d2d2)); + pointer-events: none; +} diff --git a/elements/pf-v6-badge/pf-v6-badge.ts b/elements/pf-v6-badge/pf-v6-badge.ts new file mode 100644 index 0000000000..e22a11ee9c --- /dev/null +++ b/elements/pf-v6-badge/pf-v6-badge.ts @@ -0,0 +1,72 @@ +import { LitElement, html, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; + +import styles from './pf-v6-badge.css'; + +export type BadgeState = 'unread' | 'read'; + +/** + * A **badge** is used to annotate other information like a label or an object name. + * @summary Displays a numeric value as an annotation + * @slot - Badge content, typically a number or short text + * @cssprop {} --pf-v6-c-badge--Color - Text color of the badge + * @cssprop {} --pf-v6-c-badge--BackgroundColor - Background color of the badge + * @cssprop {} --pf-v6-c-badge--BorderColor - Border color of the badge + * @cssprop {} --pf-v6-c-badge--BorderWidth - Border width of the badge + * @cssprop {} --pf-v6-c-badge--BorderRadius - Border radius of the badge + * @cssprop {} --pf-v6-c-badge--MinWidth - Minimum width of the badge + * @cssprop {} --pf-v6-c-badge--PaddingInlineStart - Inline start padding + * @cssprop {} --pf-v6-c-badge--PaddingInlineEnd - Inline end padding + * @cssprop {} --pf-v6-c-badge--FontSize - Font size of the badge text + * @cssprop {} --pf-v6-c-badge--FontWeight - Font weight of the badge text + * @cssprop {} --pf-v6-c-badge--m-read--Color - Text color in read state + * @cssprop {} --pf-v6-c-badge--m-read--BackgroundColor - Background color in read state + * @cssprop {} --pf-v6-c-badge--m-read--BorderColor - Border color in read state + * @cssprop {} --pf-v6-c-badge--m-unread--Color - Text color in unread state + * @cssprop {} --pf-v6-c-badge--m-unread--BackgroundColor - Background color in unread state + * @cssprop {} --pf-v6-c-badge--m-disabled--Color - Text color when disabled + * @cssprop {} --pf-v6-c-badge--m-disabled--BackgroundColor - Background color when disabled + * @cssprop {} --pf-v6-c-badge--m-disabled--BorderColor - Border color when disabled + */ +@customElement('pf-v6-badge') +export class PfV6Badge extends LitElement { + static readonly styles: CSSStyleSheet[] = [styles]; + + /** + * Denotes the state-of-affairs this badge represents. + */ + @property({ reflect: true }) state?: BadgeState; + + /** + * Sets a numeric value for a badge. + * + * You can pair it with `threshold` attribute to add a `+` sign + * if the number exceeds the threshold value. + */ + @property({ type: Number }) number?: number; + + /** + * Sets a threshold for the numeric value and adds `+` sign if + * the numeric value exceeds the threshold value. + */ + @property({ type: Number }) threshold?: number; + + /** Disables the badge */ + @property({ type: Boolean, reflect: true }) disabled = false; + + override render(): TemplateResult { + const { threshold, number } = this; + const displayText = + (threshold && number && (threshold <= number)) ? `${threshold.toString()}+` + : (number != null) ? number.toString() + : ''; + return html`${!displayText ? html`` : displayText}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-v6-badge': PfV6Badge; + } +} diff --git a/elements/pf-v6-badge/test/pf-v6-badge.spec.ts b/elements/pf-v6-badge/test/pf-v6-badge.spec.ts new file mode 100644 index 0000000000..ad2dee0e5b --- /dev/null +++ b/elements/pf-v6-badge/test/pf-v6-badge.spec.ts @@ -0,0 +1,181 @@ +import { expect, html } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { getColor, hexToRgb } from '@patternfly/pfe-tools/test/hex-to-rgb.js'; +import { PfV6Badge } from '@patternfly/elements/pf-v6-badge/pf-v6-badge.js'; + +describe('', function() { + it('imperatively instantiates', function() { + expect(document.createElement('pf-v6-badge')).to.be.an.instanceof(PfV6Badge); + }); + + it('should upgrade', async function() { + const el = await createFixture(html`10`); + expect(el) + .to.be.an.instanceOf(customElements.get('pf-v6-badge')) + .and + .to.be.an.instanceOf(PfV6Badge); + }); + + describe('with number attribute', function() { + let element: PfV6Badge; + + beforeEach(async function() { + element = await createFixture(html` + 100 + `); + await element.updateComplete; + }); + + it('should have the number property set', function() { + expect(element.number).to.equal(100); + }); + + it('should be visible in the accessibility tree', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.length).to.be.greaterThan(0); + const badgeNode = snapshot.children?.find( + (child: { name?: string }) => child.name?.includes('100') + ); + expect(badgeNode).to.exist; + }); + }); + + describe('with number exceeding threshold', function() { + let element: PfV6Badge; + + beforeEach(async function() { + element = await createFixture(html` + 900 + `); + await element.updateComplete; + }); + + it('should display threshold with "+" in the accessibility tree', async function() { + const snapshot = await a11ySnapshot(); + const badgeNode = snapshot.children?.find( + (child: { name?: string }) => child.name?.includes('100+') + ); + expect(badgeNode).to.exist; + }); + }); + + describe('with number below threshold', function() { + let element: PfV6Badge; + + beforeEach(async function() { + element = await createFixture(html` + 50 + `); + await element.updateComplete; + }); + + it('should display the number without "+" in the accessibility tree', async function() { + const snapshot = await a11ySnapshot(); + const badgeNode = snapshot.children?.find( + (child: { name?: string }) => + child.name?.includes('50') && !child.name?.includes('+') + ); + expect(badgeNode).to.exist; + }); + }); + + describe('without state attribute', function() { + let element: PfV6Badge; + + beforeEach(async function() { + element = await createFixture(html` + 10 + `); + await element.updateComplete; + }); + + it('should display default background color', function() { + const [r, g, b] = getColor(element, 'background-color'); + expect([r, g, b]).to.deep.equal(hexToRgb('#f0f0f0')); + }); + }); + + describe('with state="read"', function() { + let element: PfV6Badge; + + beforeEach(async function() { + element = await createFixture(html` + 10 + `); + await element.updateComplete; + }); + + it('should display read background color', function() { + const [r, g, b] = getColor(element, 'background-color'); + expect([r, g, b]).to.deep.equal(hexToRgb('#f0f0f0')); + }); + }); + + describe('with state="unread"', function() { + let element: PfV6Badge; + + beforeEach(async function() { + element = await createFixture(html` + 10 + `); + await element.updateComplete; + }); + + it('should display unread background color', function() { + const [r, g, b] = getColor(element, 'background-color'); + expect([r, g, b]).to.deep.equal(hexToRgb('#0066cc')); + }); + + it('should display unread text color', function() { + const [r, g, b] = getColor(element, 'color'); + expect([r, g, b]).to.deep.equal(hexToRgb('#ffffff')); + }); + }); + + describe('with disabled attribute', function() { + let element: PfV6Badge; + + beforeEach(async function() { + element = await createFixture(html` + 10 + `); + await element.updateComplete; + }); + + it('should display disabled background color', function() { + const [r, g, b] = getColor(element, 'background-color'); + expect([r, g, b]).to.deep.equal(hexToRgb('#d2d2d2')); + }); + + it('should have pointer-events: none', function() { + const styles = getComputedStyle(element); + expect(styles.pointerEvents).to.equal('none'); + }); + }); + + describe('accessibility', function() { + it('should contain text in the accessibility tree', async function() { + await createFixture(html` + 10 + `); + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.length).to.be.greaterThan(0); + }); + }); + + describe('slot content', function() { + let element: PfV6Badge; + + beforeEach(async function() { + element = await createFixture(html` + Custom Text + `); + await element.updateComplete; + }); + + it('should display slotted text content when no number is set', function() { + expect(element.textContent?.trim()).to.equal('Custom Text'); + }); + }); +});