diff --git a/.changeset/nasty-ravens-joke.md b/.changeset/nasty-ravens-joke.md new file mode 100644 index 0000000000..5c8abb9614 --- /dev/null +++ b/.changeset/nasty-ravens-joke.md @@ -0,0 +1,13 @@ +--- +"@rhds/elements": minor +--- + +✨ Added `` + +A disclosure is a widget that enables content to be either collapsed (hidden) or expanded (visible). + +```html + +

Lorem ipsum dolor sit amet consectetur adipisicing, elit.

+
+``` diff --git a/docs/_data/repoStatus.ts b/docs/_data/repoStatus.ts index 3e2813c1dc..358c4ca748 100644 --- a/docs/_data/repoStatus.ts +++ b/docs/_data/repoStatus.ts @@ -199,6 +199,18 @@ export default [ docs: 'ready', }, }, + { + tagName: 'rh-disclosure', + name: 'Disclosure', + type: 'element', + overallStatus: 'ready', + libraries: { + figma: 'ready', + rhds: 'ready', + shared: 'ready', + docs: 'ready', + }, + }, { tagName: 'rh-footer', name: 'Footer', diff --git a/elements/rh-disclosure/README.md b/elements/rh-disclosure/README.md new file mode 100644 index 0000000000..63ecb8ffaf --- /dev/null +++ b/elements/rh-disclosure/README.md @@ -0,0 +1,27 @@ +# Disclosure + +A disclosure is a widget that enables content to be either +collapsed (hidden) or expanded (visible). + +## Usage + +Place the following markup on your page: + +```html + +

Lorem ipsum dolor sit amet consectetur adipisicing, elit. Velit distinctio, nesciunt nobis sit.

+
+``` + +### Rich summaries + +When summary content should be rich HTML, use the `summary` slot instead of the `summary` attribute + +```html + + + Rich summary content + +

Lorem ipsum dolor sit amet consectetur adipisicing, elit. Velit distinctio, nesciunt nobis sit.

+
+``` diff --git a/elements/rh-disclosure/demo/color-context.html b/elements/rh-disclosure/demo/color-context.html new file mode 100644 index 0000000000..b74615cd9a --- /dev/null +++ b/elements/rh-disclosure/demo/color-context.html @@ -0,0 +1,12 @@ + + +

Lorem ipsum dolor sit amet consectetur adipisicing, elit. Velit distinctio, nesciunt nobis sit.

+
+
+ + + + diff --git a/elements/rh-disclosure/demo/events.html b/elements/rh-disclosure/demo/events.html new file mode 100644 index 0000000000..316223e7b3 --- /dev/null +++ b/elements/rh-disclosure/demo/events.html @@ -0,0 +1,26 @@ +
+ +

Lorem ipsum dolor sit amet consectetur adipisicing, elit. Velit distinctio, nesciunt nobis sit, a dolor, non numquam rerum recusandae, deserunt enim assumenda quidem. Id impedit necessitatibus obcaecati ratione reprehenderit laborum?

+
+ +
+ Events Fired + No events yet +
+
+ + + + diff --git a/elements/rh-disclosure/demo/nested-disclosures.html b/elements/rh-disclosure/demo/nested-disclosures.html new file mode 100644 index 0000000000..eb795bdc0d --- /dev/null +++ b/elements/rh-disclosure/demo/nested-disclosures.html @@ -0,0 +1,50 @@ + +

Be sure to test the ESC key + focus when nesting disclosures together. Lorem ipsum dolor fake link adipisicing.

+ +

You can hit escape to test focus and see which details element closes!

+ +

This is nesting! fake link 2 and more text.

+
+
+ + +
+
+ + +
+
+
+ Choose a shipping method: + +
+ +
+
+
+
+
+ Select your pizza toppings: + +
+ +
+
+
+
+ +
+
+

This is a sentence with a link.

+
+
+
+ + + + diff --git a/elements/rh-disclosure/demo/rh-disclosure.html b/elements/rh-disclosure/demo/rh-disclosure.html new file mode 100644 index 0000000000..bda4a31fe4 --- /dev/null +++ b/elements/rh-disclosure/demo/rh-disclosure.html @@ -0,0 +1,9 @@ + +

Lorem ipsum dolor sit amet consectetur adipisicing, elit. Velit distinctio, nesciunt nobis sit, a dolor, non numquam rerum recusandae, deserunt enim assumenda quidem. Id impedit necessitatibus obcaecati ratione reprehenderit laborum?

+
+ + + + diff --git a/elements/rh-disclosure/demo/slotted-summary.html b/elements/rh-disclosure/demo/slotted-summary.html new file mode 100644 index 0000000000..2efdd01632 --- /dev/null +++ b/elements/rh-disclosure/demo/slotted-summary.html @@ -0,0 +1,26 @@ + + + This is a slotted summary with extra markup + +

Instead of using <rh-disclosure summary="Hello world">, users can slot content into a summary slot and include additional HTML if needed.

+

Also note that slotted summary content will render on the page if/when JavaScript fails to load.

+
+ + + + + + diff --git a/elements/rh-disclosure/docs/00-overview.md b/elements/rh-disclosure/docs/00-overview.md new file mode 100644 index 0000000000..671def32ee --- /dev/null +++ b/elements/rh-disclosure/docs/00-overview.md @@ -0,0 +1,9 @@ +## When to use + +- When you want to have some content you want to expand and collapse +- When putting all of the content on the page might not be relevant to all users +or distract them from the main content on the page + +
+ An expanded disclosure element with a panel trigger and lorem ipsum for details content +
diff --git a/elements/rh-disclosure/docs/10-style.md b/elements/rh-disclosure/docs/10-style.md new file mode 100644 index 0000000000..a687d018ea --- /dev/null +++ b/elements/rh-disclosure/docs/10-style.md @@ -0,0 +1 @@ +## Style diff --git a/elements/rh-disclosure/docs/20-guidelines.md b/elements/rh-disclosure/docs/20-guidelines.md new file mode 100644 index 0000000000..5a11f092d4 --- /dev/null +++ b/elements/rh-disclosure/docs/20-guidelines.md @@ -0,0 +1 @@ +## Guidelines diff --git a/elements/rh-disclosure/docs/40-accessibility.md b/elements/rh-disclosure/docs/40-accessibility.md new file mode 100644 index 0000000000..f6593a1e87 --- /dev/null +++ b/elements/rh-disclosure/docs/40-accessibility.md @@ -0,0 +1 @@ +## Accessibility diff --git a/elements/rh-disclosure/docs/screenshot.png b/elements/rh-disclosure/docs/screenshot.png new file mode 100644 index 0000000000..30b2459bab Binary files /dev/null and b/elements/rh-disclosure/docs/screenshot.png differ diff --git a/elements/rh-disclosure/rh-disclosure-lightdom-shim.css b/elements/rh-disclosure/rh-disclosure-lightdom-shim.css new file mode 100644 index 0000000000..20c18765de --- /dev/null +++ b/elements/rh-disclosure/rh-disclosure-lightdom-shim.css @@ -0,0 +1,27 @@ +rh-disclosure:not(:defined) { + --rh-color-border-subtle: var(--rh-color-gray-30, #c7c7c7); + + border: var(--rh-border-width-sm, 1px) solid var(--rh-color-border-subtle); + box-shadow: var(--rh-box-shadow-sm, 0 2px 4px 0 rgba(21, 21, 21, 0.2)); + display: block; + font-family: var(--rh-font-family-body-text); + padding: var(--rh-space-lg, 16px) var(--rh-space-xl, 24px); + position: relative; + + &:before { + content: ''; + border-inline-start: 3px solid var(--rh-color-brand-red-on-light, #ee0000); + position: absolute; + z-index: 1; + inset-inline-start: -1px; + inset-block: -1px; + } +} + +rh-disclosure:not(:defined) > [slot='summary'] { + display: block; + font-size: var(--rh-font-size-body-text-md, 1rem); + font-weight: var(--rh-font-weight-body-text-medium, 500); + font-family: var(--rh-font-family-body-text); + padding-block-end: var(--rh-space-2xl, 32px); +} diff --git a/elements/rh-disclosure/rh-disclosure.css b/elements/rh-disclosure/rh-disclosure.css new file mode 100644 index 0000000000..67bfa96d3f --- /dev/null +++ b/elements/rh-disclosure/rh-disclosure.css @@ -0,0 +1,103 @@ +:host { + --rh-color-border-subtle: var(--rh-color-gray-30, #c7c7c7); + + border: var(--rh-border-width-sm, 1px) solid var(--rh-color-border-subtle); + display: block; + font-family: var(--rh-font-family-body-text); +} + +:host:has(.on.dark) { + --rh-color-border-subtle: var(--rh-color-gray-50, #707070); +} + +summary { + background-color: var(--rh-color-surface); + color: var(--rh-color-text-primary); + cursor: pointer; + font-size: var(--rh-font-size-body-text-md, 1rem); + font-weight: var(--rh-font-weight-body-text-medium, 500); + list-style: none; + padding-block: var(--rh-space-lg, 16px); + padding-inline: var(--rh-space-xl, 24px); + + &::-webkit-details-marker, + &::marker { + display: none; + } + + &:hover, + &:active, + &:focus { + background-color: var(--rh-color-surface-lighter, #f2f2f2); + } + + &:active, + &:focus { + outline: var(--rh-border-width-md, 2px) solid; + outline-color: var(--rh-color-interactive-primary-default); + outline-offset: -2px; + position: relative; + z-index: 2; + } + + & ::slotted([slot='summary']) { + font-family: var(--rh-font-family-body-text); + font-size: var(--rh-font-size-body-text-md, 1rem) !important; + font-weight: var(--rh-font-weight-body-text-medium, 500); + } +} + +.on.dark summary { + &:hover, + &:active, + &:focus { + background-color: var(--rh-color-surface-dark-alt, #292929); + } +} + +#caret { + inline-size: var(--rh-space-lg, 16px); + block-size: var(--rh-space-lg, 16px); + transition: 0.2s; + will-change: rotate; + position: relative; + inset-block-start: 3px; + margin-inline-end: var(--rh-space-md, 8px); +} + +#details-content { + background-color: var(--rh-color-surface); + color: var(--rh-color-text-primary); + font-size: var(--rh-font-size-body-text-md, 1rem); + line-height: var(--rh-line-height-body-text, 1.5); + padding-block: var(--rh-space-lg, 16px) var(--rh-space-xl, 24px); + padding-inline: var(--rh-space-xl, 24px); +} + +::slotted(:is(p, h1, h2, h3, h4, h5, h6):first-of-type) { + margin-block-start: 0; +} + +::slotted(:is(p, li, dd):last-of-type) { + margin-block-end: 0 !important; +} + +:host:has(details[open]) { + box-shadow: var(--rh-box-shadow-sm, 0 2px 4px 0 rgba(21, 21, 21, 0.2)); + position: relative; + + &:before { + content: ''; + border-inline-start: 3px solid var(--rh-color-brand-red-on-light, #ee0000); + position: absolute; + z-index: 1; + inset-inline-start: -1px; + inset-block: -1px; + } +} + +details[open] { + #caret { + transform: rotate(-180deg); + } +} diff --git a/elements/rh-disclosure/rh-disclosure.ts b/elements/rh-disclosure/rh-disclosure.ts new file mode 100644 index 0000000000..2ab8a1a5ce --- /dev/null +++ b/elements/rh-disclosure/rh-disclosure.ts @@ -0,0 +1,127 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { query } from 'lit/decorators/query.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { colorContextConsumer, type ColorTheme } from '../../lib/context/color/consumer.js'; +import { colorContextProvider, type ColorPalette } from '../../lib/context/color/provider.js'; + +import '@rhds/elements/rh-icon/rh-icon.js'; + +import styles from './rh-disclosure.css'; + +export class DisclosureToggleEvent extends Event { + constructor() { + super('toggle', { bubbles: true, cancelable: true }); + } +} + +/** + * @summary A disclosure is a widget that enables content to be either collapsed (hidden) or expanded (visible). + * @slot - Place the content you want to disclose in the default slot. This content is hidden by default. + * @slot summary - The title of the disclosure + * @fires {DisclosureToggleEvent} toggle - Fires when a user opens or closes a disclosure. + * @csspart caret - The caret icon in the shadow DOM + */ +@customElement('rh-disclosure') +export class RhDisclosure extends LitElement { + /** Sets color theme based on parent context */ + @colorContextConsumer() private on?: ColorTheme; + + static readonly styles = [styles]; + + /** + * Set the colorPalette of the disclosure. Possible values are: + * - `lightest` (default) + * - `lighter` + * - `light` + * - `dark` + * - `darker` + * - `darkest` + */ + @colorContextProvider() + @property({ reflect: true, attribute: 'color-palette' }) colorPalette?: ColorPalette; + + /** + * Sets the disclosure to be in its open state + */ + @property({ type: Boolean, reflect: true }) open = false; + + /** + * Sets the disclosure title via an attribute + */ + @property({ reflect: true }) summary?: string; + + @query('details') private detailsEl!: HTMLDetailsElement; + @query('summary') private summaryEl!: HTMLElement; + + render() { + const { on = 'light' } = this; + return html` +
+ + + ${this.summary} + +
+ +
+
+ `; + } + + #onToggle(): void { + this.open = this.detailsEl.open; + const event = new DisclosureToggleEvent(); + this.dispatchEvent(event); + } + + #onKeydown(event: KeyboardEvent): void { + const preventEscElements = ` + input:not([type='hidden']):not([type='radio']):not([inert]):not([inert] *):not([tabindex^='-']):not(:disabled), + input[type='radio']:not([inert]):not([inert] *):not([tabindex^='-']):not(:disabled), + select:not([inert]):not([inert] *):not([tabindex^='-']):not(:disabled), + textarea:not([inert]):not([inert] *):not([tabindex^='-']):not(:disabled), + iframe:not([inert]):not([inert] *):not([tabindex^='-']), + audio[controls]:not([inert]):not([inert] *):not([tabindex^='-']), + video[controls]:not([inert]):not([inert] *):not([tabindex^='-']), + [contenteditable]:not([inert]):not([inert] *):not([tabindex^='-']), + rh-audio-player:not([inert]):not([inert] *):not([tabindex^='-']):not(:disabled), + rh-dialog:not([inert]):not([inert] *):not([tabindex^='-']):not(:disabled) + `; + + if (event.code === 'Escape') { + event.stopPropagation(); + + const escapeGuardElement = + event.composedPath().reverse().find((element: EventTarget | null) => { + return (element instanceof Element && element.matches(preventEscElements)); + }); + + if (!escapeGuardElement) { + this.#closeDisclosure(); + } + } + } + + + #closeDisclosure(): void { + if (!this.open) { + return; + } + this.detailsEl.open = false; + this.open = false; + this.summaryEl.focus(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'rh-disclosure': RhDisclosure; + } +} diff --git a/elements/rh-disclosure/test/rh-disclosure.e2e.ts b/elements/rh-disclosure/test/rh-disclosure.e2e.ts new file mode 100644 index 0000000000..78d4d6d1d9 --- /dev/null +++ b/elements/rh-disclosure/test/rh-disclosure.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 = 'rh-disclosure'; + +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/rh-disclosure/test/rh-disclosure.spec.ts b/elements/rh-disclosure/test/rh-disclosure.spec.ts new file mode 100644 index 0000000000..30db395447 --- /dev/null +++ b/elements/rh-disclosure/test/rh-disclosure.spec.ts @@ -0,0 +1,40 @@ +import { expect, html } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { RhDisclosure } from '@rhds/elements/rh-disclosure/rh-disclosure.js'; + +describe('', function() { + describe('simply instantiating', function() { + let element: RhDisclosure; + it('imperatively instantiates', function() { + expect(document.createElement('rh-disclosure')).to.be.an.instanceof(RhDisclosure); + }); + + it('should upgrade', async function() { + element = await createFixture(html``); + const klass = customElements.get('rh-disclosure'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(RhDisclosure); + }); + }); + + describe('when the element loads', function() { + let element: RhDisclosure; + beforeEach(async function() { + element = await createFixture(html` + + + Summary title + +

Details content goes here.

+
+ `); + await element.updateComplete; + }); + + it('should be accessible', async function() { + await expect(element).to.be.accessible(); + }); + }); +});