diff --git a/.changeset/kind-lions-help.md b/.changeset/kind-lions-help.md new file mode 100644 index 0000000000..e6ccd1d172 --- /dev/null +++ b/.changeset/kind-lions-help.md @@ -0,0 +1,26 @@ +--- +"@patternfly/elements": major +--- + +✨ Added `` replacing ``. Helper text now follows +PatternFly v6 design specs. + +```html +Password is too short +``` + +**Breaking Changes from v5** + +- Renamed tag from `` to `` +- Update bare module import to point to v6: `import '@patternfly/elements/pf-v6-helper-text/pf-v6-helper-text.js'` +- ✨ Added `has-icon` boolean attribute for opt-in default variant icons +- ✨ Added `dynamic` boolean attribute for dynamic validation contexts +- ✨ Added `screen-reader-text` attribute for custom assistive announcements +- ✨ Added `icon` slot for custom icon markup (replaces `icon` and `icon-set` attributes) +- ✨ Added `icon` and `text` CSS parts +- ✨ Added `color-scheme` support via `light-dark()` +- ✨ Added v6 design tokens +- CSS custom properties renamed from `--pf-v5-c-helper-text--*` to `--pf-v6-c-helper-text--*` +- Removed `icon` attribute (use `has-icon` for default icons or `icon` slot for custom) +- Removed `icon-set` attribute +- Removed dependency on `` (uses inline SVG icons) diff --git a/docs/main.mjs b/docs/main.mjs index 2664df6bb8..d21ad2767f 100644 --- a/docs/main.mjs +++ b/docs/main.mjs @@ -13,7 +13,7 @@ import '@patternfly/elements/pf-v5-chip/pf-v5-chip.js'; import '@patternfly/elements/pf-v5-clipboard-copy/pf-v5-clipboard-copy.js'; import '@patternfly/elements/pf-v5-code-block/pf-v5-code-block.js'; import '@patternfly/elements/pf-v5-dropdown/pf-v5-dropdown.js'; -import '@patternfly/elements/pf-v5-helper-text/pf-v5-helper-text.js'; +import '@patternfly/elements/pf-v6-helper-text/pf-v6-helper-text.js'; import '@patternfly/elements/pf-v5-hint/pf-v5-hint.js'; import '@patternfly/elements/pf-v5-jump-links/pf-v5-jump-links.js'; import '@patternfly/elements/pf-v5-label/pf-v5-label.js'; diff --git a/elements/pf-v5-helper-text/demo/dynamic-list.html b/elements/pf-v5-helper-text/demo/dynamic-list.html deleted file mode 100644 index cd883df086..0000000000 --- a/elements/pf-v5-helper-text/demo/dynamic-list.html +++ /dev/null @@ -1,30 +0,0 @@ - -
    -
  • - Must be at least 14 characters -
  • -
  • - Cannot contain any variation of the word "redhat" -
  • -
  • - Must include at least 3 of the following: lowercase letter, uppercase letters, numbers, symbols -
  • -
-
- - - - diff --git a/elements/pf-v5-helper-text/demo/dynamic.html b/elements/pf-v5-helper-text/demo/dynamic.html deleted file mode 100644 index 920ec038c4..0000000000 --- a/elements/pf-v5-helper-text/demo/dynamic.html +++ /dev/null @@ -1,42 +0,0 @@ - - This is default helper text - - - - This is indeterminate helper text - - - - This is warning helper text - - - - This is success helper text - - - - This is error helper text - - - - - diff --git a/elements/pf-v5-helper-text/demo/index.html b/elements/pf-v5-helper-text/demo/index.html deleted file mode 100644 index 2e06189fb7..0000000000 --- a/elements/pf-v5-helper-text/demo/index.html +++ /dev/null @@ -1 +0,0 @@ -Success! diff --git a/elements/pf-v5-helper-text/demo/multiple.html b/elements/pf-v5-helper-text/demo/multiple.html deleted file mode 100644 index b698d9c78b..0000000000 --- a/elements/pf-v5-helper-text/demo/multiple.html +++ /dev/null @@ -1,11 +0,0 @@ - -
    -
  • This is default helper text
  • -
  • This is another default helper text in the same block
  • -
  • And this is more default text in the same block
  • -
-
- - diff --git a/elements/pf-v5-helper-text/demo/static-icons.html b/elements/pf-v5-helper-text/demo/static-icons.html deleted file mode 100644 index 8af0bcf24c..0000000000 --- a/elements/pf-v5-helper-text/demo/static-icons.html +++ /dev/null @@ -1,37 +0,0 @@ - - This is default helper text - - - - This is indeterminate helper text - - - - This is warning helper text - - - - This is success helper text - - - - This is error helper text - - - - - diff --git a/elements/pf-v5-helper-text/demo/static.html b/elements/pf-v5-helper-text/demo/static.html deleted file mode 100644 index ffee3e1851..0000000000 --- a/elements/pf-v5-helper-text/demo/static.html +++ /dev/null @@ -1,16 +0,0 @@ -This is default helper text -This is indeterminate helper text -This is warning helper text -This is success helper text -This is error helper text - - - - diff --git a/elements/pf-v5-helper-text/docs/pf-v5-helper-text.md b/elements/pf-v5-helper-text/docs/pf-v5-helper-text.md deleted file mode 100644 index e9ffe85f78..0000000000 --- a/elements/pf-v5-helper-text/docs/pf-v5-helper-text.md +++ /dev/null @@ -1,17 +0,0 @@ -{% renderOverview %} - -{% endrenderOverview %} - -{% band header="Usage" %}{% endband %} - -{% renderSlots %}{% endrenderSlots %} - -{% renderAttributes %}{% endrenderAttributes %} - -{% renderMethods %}{% endrenderMethods %} - -{% renderEvents %}{% endrenderEvents %} - -{% renderCssCustomProperties %}{% endrenderCssCustomProperties %} - -{% renderCssParts %}{% endrenderCssParts %} diff --git a/elements/pf-v5-helper-text/pf-v5-helper-text.css b/elements/pf-v5-helper-text/pf-v5-helper-text.css deleted file mode 100644 index a0c85dd861..0000000000 --- a/elements/pf-v5-helper-text/pf-v5-helper-text.css +++ /dev/null @@ -1,35 +0,0 @@ -:host { - display: flex; - align-items: center; - gap: var(--pf-v5-c-helper-text--Gap, 0.25rem); - font-size: var(--pf-v5-c-helper-text--FontSize, 0.875rem); - color: var(--pf-v5-c-helper-text__item-text--Color, #151515); - line-height: 1.4; -} - -/* Color variants */ -:host([variant='indeterminate']) { - color: var(--pf-v5-c-helper-text__item-text--m-indeterminate--Color, #6a6e73); -} - -:host([variant='warning']) { - color: var(--pf-v5-c-helper-text__item-text--m-warning--Color, #795600); -} - -:host([variant='success']) { - color: var(--pf-v5-c-helper-text__item-text--m-success--Color, #1e4f18); -} - -:host([variant='error']) { - color: var(--pf-v5-c-helper-text__item-text--m-error--Color, #a30000); -} - -::slotted(ul) { - margin: 0; - padding: 0; - list-style-type: none; -} - -pf-v5-icon { - fill: currentColor; -} diff --git a/elements/pf-v5-helper-text/pf-v5-helper-text.ts b/elements/pf-v5-helper-text/pf-v5-helper-text.ts deleted file mode 100644 index ca46eb51b7..0000000000 --- a/elements/pf-v5-helper-text/pf-v5-helper-text.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { LitElement, html, type TemplateResult } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; - -import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; - -import '@patternfly/elements/pf-v5-icon/pf-v5-icon.js'; - -import styles from './pf-v5-helper-text.css'; - -/** Map of status to default icons (Font Awesome solid set). */ -const StatusIconMap = { - success: 'check-circle', - warning: 'exclamation-triangle', - error: 'exclamation-circle', - indeterminate: 'info-circle', -}; - -/** - * Displays contextual feedback for form fields with optional icon and status color. - * - * @alias Helper Text - * @slot icon - Optional custom icon to override the default icon. - * @slot - Default slot for the helper text content. - * - * @fires icon-load - Fired when the icon successfully loads. - * @fires icon-error - Fired if loading the icon fails. - * - * @csspart icon - The container for the icon. - * @csspart text - The container for the text. - */ -@customElement('pf-v5-helper-text') -export class PfV5HelperText extends LitElement { - public static readonly styles: CSSStyleSheet[] = [styles]; - - /** - * Defines the helper text status and its corresponding color and icon. - */ - @property({ reflect: true }) variant: - | 'default' - | 'success' - | 'warning' - | 'error' - | 'indeterminate' = 'default'; - - /** - * Custom icon name to override the default icon. - * Requires `` to be imported. - */ - @property() icon?: string; - - /** - * Icon set for custom icons (e.g., 'fas', 'patternfly'). - */ - @property({ attribute: 'icon-set' }) iconSet?: string; - - #slots = new SlotController(this, 'icon', null); - - /** - * Determine the effective icon to display. - */ - private get _resolvedIcon(): string | undefined { - if (this.icon) { - return this.icon; - } - if (this.variant !== 'default') { - return StatusIconMap[this.variant]; - } - return undefined; - } - - protected render(): TemplateResult<1> { - const iconName = this._resolvedIcon; - const showIcon = !!this.icon || this.#slots.hasSlotted('icon'); - - return html` - - - - - - - - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'pf-v5-helper-text': PfV5HelperText; - } -} diff --git a/elements/pf-v5-helper-text/test/pf-helper-text.e2e.ts b/elements/pf-v5-helper-text/test/pf-helper-text.e2e.ts deleted file mode 100644 index ab593d7b37..0000000000 --- a/elements/pf-v5-helper-text/test/pf-helper-text.e2e.ts +++ /dev/null @@ -1,25 +0,0 @@ -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-v5-helper-text'; - -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-v5-helper-text/test/pf-helper-text.spec.ts b/elements/pf-v5-helper-text/test/pf-helper-text.spec.ts deleted file mode 100644 index fb3d24f7b7..0000000000 --- a/elements/pf-v5-helper-text/test/pf-helper-text.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, html } from '@open-wc/testing'; -import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; -import { PfV5HelperText } from '@patternfly/elements/pf-v5-helper-text/pf-v5-helper-text.js'; - -describe('', function() { - describe('simply instantiating', function() { - let element: PfV5HelperText; - it('imperatively instantiates', function() { - expect(document.createElement('pf-v5-helper-text')).to.be.an.instanceof(PfV5HelperText); - }); - - it('should upgrade', async function() { - element = await createFixture(html``); - const klass = customElements.get('pf-v5-helper-text'); - expect(element) - .to.be.an.instanceOf(klass) - .and - .to.be.an.instanceOf(PfV5HelperText); - }); - }); - - describe('variant property', function() { - it('should default to "default" variant', async function() { - const element = await createFixture(html``); - expect(element.variant).to.equal('default'); - }); - - it('should reflect variant attribute', async function() { - const element = await createFixture(html``); - expect(element.variant).to.equal('success'); - expect(element.getAttribute('variant')).to.equal('success'); - }); - }); - - describe('icon display', function() { - it('should show icon when icon property is set', async function() { - const element = await createFixture(html`Success`); - await element.updateComplete; - const iconContainer = element.shadowRoot?.querySelector('#icon'); - expect(iconContainer?.hasAttribute('hidden')).to.be.false; - }); - - it('should hide icon when no icon or slotted icon is present', async function() { - const element = await createFixture(html`Text`); - await element.updateComplete; - const iconContainer = element.shadowRoot?.querySelector('#icon'); - expect(iconContainer?.hasAttribute('hidden')).to.be.true; - }); - }); - - describe('text content', function() { - it('should render text content in default slot', async function() { - const element = await createFixture(html`Helper text content`); - expect(element.textContent?.trim()).to.equal('Helper text content'); - }); - - it('should have aria-live on text container', async function() { - const element = await createFixture(html`Text`); - await element.updateComplete; - const textContainer = element.shadowRoot?.querySelector('#text'); - expect(textContainer?.getAttribute('aria-live')).to.equal('polite'); - }); - }); -}); diff --git a/elements/pf-v6-helper-text/README.md b/elements/pf-v6-helper-text/README.md new file mode 100644 index 0000000000..f39d503775 --- /dev/null +++ b/elements/pf-v6-helper-text/README.md @@ -0,0 +1,65 @@ +# `` + +A helper text item displays contextual feedback or validation messages for +form fields, with status variants and optional icons. Supports light and +dark color schemes via `light-dark()` CSS fallbacks. + +## Usage + +```html + +This is default helper text + + +Password is too short + + +
+ At least 14 characters + Must not contain "redhat" +
+ + + + + Custom icon helper text + +``` + +## Divergences from React `HelperText` / `HelperTextItem` + +React splits this into two components: `HelperText` (container) and +`HelperTextItem` (individual item). Our web component is a single element +representing one item. For grouping, use standard HTML containers with +appropriate ARIA attributes. + +### Not implemented + +| React prop | Notes | +| --- | --- | +| `HelperText` `component` (`'div'` \| `'ul'`) | Use a standard HTML `
` or `
    ` wrapper instead. | +| `HelperText` `isLiveRegion` | Apply `aria-live="polite"` directly on a wrapper element. | +| `HelperText` `aria-label` | Apply `aria-label` directly on the wrapper element. | +| `HelperTextItem` `component` (`'div'` \| `'li'`) | The custom element renders as its own tag; use `role="listitem"` if needed. | + +### Changed API + +| React prop | Web component | Difference | +| --- | --- | --- | +| `HelperTextItem` `variant` | `variant` attribute | Same values (`default`, `indeterminate`, `warning`, `success`, `error`). Identical behavior for styling. Icon display is controlled separately via `has-icon`. | +| `HelperTextItem` `icon` (ReactNode) | `icon` slot | React accepts a React node as a prop. Web component uses a named slot for custom icon markup. | +| `HelperTextItem` `screenReaderText` | `screen-reader-text` attribute | Same behavior: announces variant status to assistive tech. Defaults to "${variant} status" for non-default variants. Set to empty string to suppress. | + +### Added + +| Web component API | Notes | +| --- | --- | +| `has-icon` attribute | Opt-in to display the default variant icon. React shows icons automatically for non-default variants; we require explicit opt-in. Has no effect when `variant` is `"default"`. | +| `dynamic` attribute | Enables dynamic item styling (maps to React's `pf-m-dynamic` modifier class). Affects icon color precedence in dynamic validation contexts. | +| `icon` CSS part | Exposes the icon container for external styling. | +| `text` CSS part | Exposes the text container for external styling. | +| Dark mode support | Built-in `light-dark()` CSS fallbacks. React relies on global PF theme CSS variables for dark mode; our element works standalone. | +| `--pf-v6-c-helper-text__item-icon--Color` | Default icon color. | +| `--pf-v6-c-helper-text__item-text--Color` | Default text color. | +| `--pf-v6-c-helper-text--FontSize` | Font size (default: 0.75rem). | +| `--pf-v6-c-helper-text__item-icon--MarginInlineEnd` | Gap between icon and text (default: 0.25rem). | diff --git a/elements/pf-v6-helper-text/demo/dynamic.html b/elements/pf-v6-helper-text/demo/dynamic.html new file mode 100644 index 0000000000..7a175099b6 --- /dev/null +++ b/elements/pf-v6-helper-text/demo/dynamic.html @@ -0,0 +1,25 @@ +--- +description: Dynamic helper text that updates with form validation state, using aria-live for announcements. +--- +
    + + Must be at least 14 characters + + + Cannot contain any variation of the word "redhat" + + + Must include at least 3 of the following: lowercase letter, uppercase letters, numbers, symbols + +
    + + + + diff --git a/elements/pf-v6-helper-text/demo/index.html b/elements/pf-v6-helper-text/demo/index.html new file mode 100644 index 0000000000..426bdc6a2b --- /dev/null +++ b/elements/pf-v6-helper-text/demo/index.html @@ -0,0 +1,18 @@ +--- +description: Basic helper text items demonstrating each status variant with default icons. +--- +This is default helper text +This is indeterminate helper text +This is warning helper text +This is success helper text +This is error helper text + + + + diff --git a/elements/pf-v6-helper-text/demo/multiple.html b/elements/pf-v6-helper-text/demo/multiple.html new file mode 100644 index 0000000000..ed10db0aae --- /dev/null +++ b/elements/pf-v6-helper-text/demo/multiple.html @@ -0,0 +1,19 @@ +--- +description: Multiple helper text items grouped within a container element using list semantics. +--- +
    + This is default helper text + This is another default helper text in the same block + And this is more default text in the same block +
    + + + + diff --git a/elements/pf-v6-helper-text/demo/with-custom-icons.html b/elements/pf-v6-helper-text/demo/with-custom-icons.html new file mode 100644 index 0000000000..d6f303b68a --- /dev/null +++ b/elements/pf-v6-helper-text/demo/with-custom-icons.html @@ -0,0 +1,47 @@ +--- +description: Helper text items with custom SVG icons slotted into the icon slot. +--- + + + This is default helper text + + + + + This is indeterminate helper text + + + + + This is warning helper text + + + + + This is success helper text + + + + + This is error helper text + + + + + diff --git a/elements/pf-v6-helper-text/pf-v6-helper-text.css b/elements/pf-v6-helper-text/pf-v6-helper-text.css new file mode 100644 index 0000000000..a256e06dee --- /dev/null +++ b/elements/pf-v6-helper-text/pf-v6-helper-text.css @@ -0,0 +1,146 @@ +:host { + display: flex; + align-items: flex-start; + gap: + /** Space between icon and text. Maps to --pf-t--global--spacer--xs. */ + var(--pf-v6-c-helper-text__item-icon--MarginInlineEnd, 0.25rem); + font-size: + /** Base font size for helper text items. Maps to --pf-t--global--font--size--body--sm. */ + var(--pf-v6-c-helper-text--FontSize, 0.75rem); + font-weight: + /** Font weight for helper text items. Maps to --pf-t--global--font--weight--body--default. */ + var(--pf-v6-c-helper-text__item-text--FontWeight, 400); + color: + /** Text color for helper text items. Maps to --pf-t--global--text--color--regular. */ + var(--pf-v6-c-helper-text__item-text--Color, light-dark(#151515, #e0e0e0)); + line-height: 1.5; +} + +:host([variant="indeterminate"]) { + color: + /** Text color for indeterminate variant. Maps to --pf-t--global--text--color--subtle. */ + var(--pf-v6-c-helper-text__item-text--m-indeterminate--Color, light-dark(#4d4d4d, #a3a3a3)); + + #icon { + color: + /** Icon color for indeterminate variant. Maps to --pf-t--global--icon--color--subtle. */ + var(--pf-v6-c-helper-text__item-icon--m-indeterminate--Color, light-dark(#707070, #8f8f8f)); + } +} + +:host([variant="warning"]) { + font-weight: + /** Font weight for warning variant. Maps to --pf-t--global--font--weight--body--bold. */ + var(--pf-v6-c-helper-text__item-text--m-warning--FontWeight, 500); + + #icon { + color: + /** Icon color for warning variant. Maps to --pf-t--global--icon--color--status--warning--default. */ + var(--pf-v6-c-helper-text__item-icon--m-warning--Color, light-dark(#dca614, #f0ab00)); + } +} + +:host([variant="success"]) { + font-weight: + /** Font weight for success variant. Maps to --pf-t--global--font--weight--body--bold. */ + var(--pf-v6-c-helper-text__item-text--m-success--FontWeight, 500); + + #icon { + color: + /** Icon color for success variant. Maps to --pf-t--global--icon--color--status--success--default. */ + var(--pf-v6-c-helper-text__item-icon--m-success--Color, light-dark(#3d7317, #5ba352)); + } +} + +:host([variant="error"]) { + font-weight: + /** Font weight for error variant. Maps to --pf-t--global--font--weight--body--bold. */ + var(--pf-v6-c-helper-text__item-text--m-error--FontWeight, 500); + animation: item-fade-in + /** Fade-in duration for error items. Maps to --pf-t--global--motion--duration--fade--default. */ + var(--pf-v6-c-helper-text__item--m-error--TransitionDuration--Opacity, 200ms) + /** Fade-in easing for error items. Maps to --pf-t--global--motion--timing-function--default. */ + var(--pf-v6-c-helper-text__item--m-error--TransitionTimingFunction--Opacity, cubic-bezier(0.4, 0, 0.2, 1)); + + #icon { + color: + /** Icon color for error variant. Maps to --pf-t--global--icon--color--status--danger--default. */ + var(--pf-v6-c-helper-text__item-icon--m-error--Color, light-dark(#b1380b, #fe5142)); + } +} + +:host([dynamic]) { + #icon { + color: + /** Icon color for dynamic default items. Maps to --pf-t--global--icon--color--regular. */ + var(--pf-v6-c-helper-text--m-dynamic__item-icon--Color, light-dark(#1f1f1f, #d4d4d4)); + } +} + +:host([dynamic][variant="indeterminate"]) { + #icon { + color: + /** Icon color for dynamic indeterminate items. Maps to --pf-t--global--icon--color--subtle. */ + var(--pf-v6-c-helper-text--m-dynamic--m-indeterminate__item-icon--Color, light-dark(#707070, #8f8f8f)); + } +} + +:host([dynamic][variant="warning"]) { + #icon { + color: + /** Icon color for dynamic warning items. Maps to --pf-t--global--icon--color--status--warning--default. */ + var(--pf-v6-c-helper-text--m-dynamic--m-warning__item-icon--Color, light-dark(#dca614, #f0ab00)); + } +} + +:host([dynamic][variant="success"]) { + #icon { + color: + /** Icon color for dynamic success items. Maps to --pf-t--global--icon--color--status--success--default. */ + var(--pf-v6-c-helper-text--m-dynamic--m-success__item-icon--Color, light-dark(#3d7317, #5ba352)); + } +} + +:host([dynamic][variant="error"]) { + #icon { + color: + /** Icon color for dynamic error items. Maps to --pf-t--global--icon--color--status--danger--default. */ + var(--pf-v6-c-helper-text--m-dynamic--m-error__item-icon--Color, light-dark(#b1380b, #fe5142)); + } +} + +#icon { + display: flex; + align-items: center; + flex-shrink: 0; + block-size: 1lh; + color: + /** Default icon color. Maps to --pf-t--global--icon--color--regular. */ + var(--pf-v6-c-helper-text__item-icon--Color, light-dark(#1f1f1f, #d4d4d4)); +} + +#icon svg { + inline-size: 1em; + block-size: 1em; +} + +[hidden] { + display: none !important; +} + +.sr-only { + position: absolute; + inline-size: 1px; + block-size: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@keyframes item-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/elements/pf-v6-helper-text/pf-v6-helper-text.ts b/elements/pf-v6-helper-text/pf-v6-helper-text.ts new file mode 100644 index 0000000000..e1d822970f --- /dev/null +++ b/elements/pf-v6-helper-text/pf-v6-helper-text.ts @@ -0,0 +1,125 @@ +import { LitElement, html, type TemplateResult } 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 styles from './pf-v6-helper-text.css'; + +export type HelperTextVariant = + | 'default' + | 'indeterminate' + | 'warning' + | 'success' + | 'error'; + +/** + * Provides contextual feedback for form fields. Authors must supply + * text content and should set `variant` for visual status. When items + * update dynamically, authors should wrap them in a container with + * `aria-live="polite"`. Authors should avoid using without an associated + * form field. + * + * @summary Contextual help or validation message for form fields. + * + * @fires {Event} slotchange - Fires when the default or icon slot content changes. The event target is the `` element whose assigned nodes changed. No custom detail. + */ +@customElement('pf-v6-helper-text') +export class PfV6HelperText extends LitElement { + static readonly styles: CSSStyleSheet[] = [styles]; + + /** + * Status variant controlling color, font weight, and default icon. + */ + @property({ reflect: true }) variant: HelperTextVariant = 'default'; + + /** + * Whether this item should display the default variant icon. + * When true, shows the built-in SVG icon for the current variant. + * Has no effect when `variant="default"` since no default icon exists. + */ + @property({ type: Boolean, attribute: 'has-icon', reflect: true }) hasIcon = false; + + /** + * Marks this item as dynamically shown/hidden, enabling the "dynamic" + * styling modifier (e.g. icon color changes in dynamic context). + */ + @property({ type: Boolean, reflect: true }) dynamic = false; + + /** + * Custom screen reader text to announce the variant status. + * Defaults to "${variant} status" for non-default variants. + * Set to empty string to suppress screen reader announcement. + */ + @property({ attribute: 'screen-reader-text' }) screenReaderText?: string; + + #slots = new SlotController(this, 'icon', null); + + /** + * Effective screen reader text. When variant is not "default", provides + * status context for assistive technologies. + */ + get #effectiveScreenReaderText(): string | undefined { + if (this.screenReaderText !== undefined) { + return this.screenReaderText; + } + if (this.variant !== 'default') { + return `${this.variant} status`; + } + return undefined; + } + + /** Whether to render the icon area */ + get #showIcon(): boolean { + return this.hasIcon || this.#slots.hasSlotted('icon'); + } + + override render(): TemplateResult<1> { + const srText = this.#effectiveScreenReaderText; + return html` + + + + + + ${!srText ? '' : html`: ${srText};`} + + `; + } + + #renderDefaultIcon(): TemplateResult<1> | string { + if (!this.hasIcon || this.variant === 'default') { + return ''; + } + switch (this.variant) { + case 'indeterminate': + return html``; + case 'warning': + return html``; + case 'success': + return html``; + case 'error': + return html``; + default: + return ''; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-v6-helper-text': PfV6HelperText; + } +} diff --git a/elements/pf-v6-helper-text/test/pf-helper-text.spec.ts b/elements/pf-v6-helper-text/test/pf-helper-text.spec.ts new file mode 100644 index 0000000000..a95c00b0d4 --- /dev/null +++ b/elements/pf-v6-helper-text/test/pf-helper-text.spec.ts @@ -0,0 +1,214 @@ +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 { PfV6HelperText } from '@patternfly/elements/pf-v6-helper-text/pf-v6-helper-text.js'; + +describe('', function() { + describe('simply instantiating', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`` + ); + }); + + it('imperatively instantiates', function() { + expect(document.createElement('pf-v6-helper-text')) + .to.be.an.instanceof(PfV6HelperText); + }); + + it('should upgrade', function() { + const klass = customElements.get('pf-v6-helper-text'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfV6HelperText); + }); + }); + + describe('variant property', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`` + ); + }); + + it('should default to "default" variant', function() { + expect(element.variant).to.equal('default'); + }); + + it('should reflect variant attribute', function() { + expect(element.getAttribute('variant')).to.equal('default'); + }); + }); + + describe('with variant="success"', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`Good job` + ); + }); + + it('should reflect variant attribute', function() { + expect(element.variant).to.equal('success'); + expect(element.getAttribute('variant')).to.equal('success'); + }); + }); + + describe('has-icon attribute', function() { + describe('when has-icon is absent', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`Text` + ); + }); + + it('should not display an icon', function() { + const icon = element.shadowRoot!.querySelector('[part="icon"]') as HTMLElement; + expect(icon.offsetWidth).to.equal(0); + }); + }); + + describe('when has-icon is present', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`Text` + ); + }); + + it('should display the icon', function() { + const icon = element.shadowRoot!.querySelector('[part="icon"]') as HTMLElement; + expect(icon.offsetWidth).to.be.greaterThan(0); + }); + }); + }); + + describe('custom icon slot', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture(html` + + + Custom icon text + + `); + }); + + it('should display the icon area when icon slot is filled', function() { + const icon = element.shadowRoot!.querySelector('[part="icon"]') as HTMLElement; + expect(icon.offsetWidth).to.be.greaterThan(0); + }); + }); + + describe('screen reader text', function() { + describe('with non-default variant', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`Error text` + ); + }); + + it('should include variant status text in text part', function() { + const textPart = element.shadowRoot!.querySelector('[part="text"]'); + expect(textPart!.textContent).to.include('error status'); + }); + }); + + describe('with default variant', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`Default text` + ); + }); + + it('should not include status text', function() { + const textPart = element.shadowRoot!.querySelector('[part="text"]'); + expect(textPart!.textContent).to.not.include('status'); + }); + }); + + describe('with custom screen-reader-text', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`Error` + ); + }); + + it('should use custom text instead of default', function() { + const textPart = element.shadowRoot!.querySelector('[part="text"]'); + expect(textPart!.textContent).to.include('danger'); + expect(textPart!.textContent).to.not.include('error status'); + }); + }); + + describe('with screen-reader-text set to empty string', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`Error` + ); + }); + + it('should suppress screen reader announcement', function() { + const textPart = element.shadowRoot!.querySelector('[part="text"]'); + expect(textPart!.textContent).to.not.include('status'); + expect(textPart!.textContent).to.not.include('danger'); + }); + }); + }); + + describe('dynamic attribute', function() { + let element: PfV6HelperText; + + beforeEach(async function() { + element = await createFixture( + html`Dynamic text` + ); + }); + + it('should reflect dynamic attribute', function() { + expect(element.dynamic).to.be.true; + expect(element.hasAttribute('dynamic')).to.be.true; + }); + }); + + describe('accessibility', function() { + it('should expose text content in accessibility tree', async function() { + await createFixture(html` + Password is too short + `); + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.some( + (child: { name?: string }) => child.name?.includes('Password is too short') + )).to.be.true; + }); + + it('should not expose icon in accessibility tree', async function() { + await createFixture(html` + Success text + `); + const snapshot = await a11ySnapshot(); + const hasImgRole = snapshot.children?.some( + (child: { role?: string }) => child.role === 'img' || child.role === 'image' + ); + expect(hasImgRole).to.not.be.true; + }); + }); +});