diff --git a/packages/components/src/components/expandable/expandable.stories.ts b/packages/components/src/components/expandable/expandable.stories.ts new file mode 100644 index 000000000..62379ef74 --- /dev/null +++ b/packages/components/src/components/expandable/expandable.stories.ts @@ -0,0 +1,251 @@ +import '../../solid-components'; +import { html } from 'lit'; +import { storybookDefaults, storybookHelpers, storybookTemplate } from '../../../scripts/storybook/helper'; +import { userEvent } from '@storybook/test'; +import { waitUntil } from '@open-wc/testing-helpers'; + +const { argTypes, parameters } = storybookDefaults('sd-expandable'); +const { overrideArgs } = storybookHelpers('sd-expandable'); +const { generateTemplate } = storybookTemplate('sd-expandable'); + +export default { + title: 'Components/sd-expandable', + component: 'sd-expandable', + args: overrideArgs([ + { type: 'slot', name: 'default', value: '
Default slot
' } + ]), + argTypes, + parameters: { ...parameters } +}; + +/** + * Expandable shows a brief summary and expands to show additional content. + */ +export const Default = { + render: (args: any) => { + return generateTemplate({ + args + }); + } +}; + +/** + * Use the inverted attribute to make an expandable with inverted colors. + */ +export const Inverted = { + parameters: { controls: { exclude: 'inverted' } }, + render: (args: any) => { + return generateTemplate({ + axis: { + y: { type: 'attribute', name: 'inverted' } + }, + args, + options: { + templateBackgrounds: { alternate: 'y', colors: ['rgb(var(--sd-color-primary, 0 53 142))', 'white'] } + } + }); + } +}; + +/** + * Use the `default`, `toggle-open` and `toggle-closed` slots to add content to the expandable. + */ +export const Slots = { + parameters: { + controls: { exclude: ['default', 'toggle-open', 'toggle-closed'] } + }, + render: (args: any) => { + return html` + ${['default', 'toggle-open', 'toggle-closed'].map(slot => + generateTemplate({ + axis: { + x: { + type: 'slot', + name: slot, + title: 'slot=...', + values: [ + { + value: + slot === 'default' + ? `
Default slot
` + : `
`, + title: slot + } + ] + } + }, + args + }) + )} + `; + } +}; + +/** + * Use the `content`, `toggle`, `summary` and `details` parts to style the expandable. + */ +export const Parts = { + parameters: { + controls: { + exclude: ['open', 'content', 'toggle', 'summary', 'details'] + } + }, + render: (args: any) => { + return generateTemplate({ + axis: { + y: { + type: 'template', + name: 'sd-expandable::part(...){outline: solid 2px red}', + values: ['content', 'toggle', 'summary', 'details'].map(part => { + return { + title: part, + value: `
%TEMPLATE%
` + }; + }) + } + }, + args + }); + } +}; + +/** + * sd-expandable is fully accessibile via keyboard. + */ + +export const Mouseless = { + render: (args: any) => { + return html`
${generateTemplate({ args })}
`; + }, + + play: async ({ canvasElement }: { canvasElement: HTMLUnknownElement }) => { + const el = canvasElement.querySelector('.mouseless sd-expandable'); + await waitUntil(() => el?.shadowRoot?.querySelector('button')); + await userEvent.type(el!.shadowRoot!.querySelector('button')!, '{return}', { pointerEventsCheck: 0 }); + } +}; + +/** + * Expandable can be used with background options of white, neutral-100 and primary-100. When using these options, use the `--gradient-color-start` and `--gradient-color-end` CSS variables to align the gradient colors. + * + * The inverted attribute can be used when the background is primary. The default slot can be used with 2 variants for alternate expandable experiences: lead text and paragraph. + */ +export const Samples = { + render: (args: any) => { + return html` + +
Background white
+
+ ${generateTemplate({ + args: { ...args } + })} +
+
+ Background neutral-100 +
+
+ ${generateTemplate({ + args: { ...args }, + constants: [ + { + type: 'cssProperty', + name: '--gradient-color-start', + value: 'rgba(246, 246, 246, 0)' + }, + { + type: 'cssProperty', + name: '--gradient-color-end', + value: 'rgba(246, 246, 246, 1)' + } + ] + })} +
+
+ Background primary-100 +
+
+ ${generateTemplate({ + args: { ...args }, + constants: [ + { + type: 'cssProperty', + name: '--gradient-color-start', + value: 'rgba(236, 240, 249, 0)' + }, + { + type: 'cssProperty', + name: '--gradient-color-end', + value: 'rgba(236, 240, 249, 1)' + } + ] + })} +
+
+ Background primary, inverted +
+
+ ${generateTemplate({ + args: { ...args, inverted: true } + })} +
+
Lead Text Example
+
+ ${generateTemplate({ + args: overrideArgs([ + { + type: 'slot', + name: 'default', + value: + '
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nibh justo ullamcorper odio tempor molestie phasellus dui vel id. Velit in sed non orci pellentesque vivamus nunc. At non tortor, sit neque tristique. Facilisis commodo integer hendrerit tortor. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nibh justo ullamcorper odio tempor molestie phasellus dui vel id. Velit in sed non orci pellentesque vivamus nunc. At non tortor, sit neque tristique. Facilisis commodo integer hendrerit tortor.
' + }, + { + type: 'attribute', + name: 'variant', + value: 'leadtext' + }, + { + type: 'cssProperty', + name: '--gradient-color-start', + value: 'rgba(246, 246, 246, 0)' + }, + { + type: 'cssProperty', + name: '--gradient-color-end', + value: 'rgba(246, 246, 246, 1)' + } + ]) + })} +
+
Paragraph Example
+
+ ${generateTemplate({ + args: overrideArgs([ + { + type: 'slot', + name: 'default', + value: + '
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nibh justo ullamcorper odio tempor molestie phasellus dui vel id. Velit in sed non orci pellentesque vivamus nunc. At non tortor, sit neque tristique. Facilisis commodo integer hendrerit tortor. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nibh justo ullamcorper odio tempor molestie phasellus dui vel id. Velit in sed non orci pellentesque vivamus nunc. At non tortor, sit neque tristique. Facilisis commodo integer hendrerit tortor.
' + }, + { + type: 'cssProperty', + name: '--gradient-color-start', + value: 'rgba(246, 246, 246, 0)' + }, + { + type: 'cssProperty', + name: '--gradient-color-end', + value: 'rgba(246, 246, 246, 1)' + } + ]) + })} +
+ `; + } +}; diff --git a/packages/components/src/components/expandable/expandable.test.ts b/packages/components/src/components/expandable/expandable.test.ts new file mode 100644 index 000000000..f07298c33 --- /dev/null +++ b/packages/components/src/components/expandable/expandable.test.ts @@ -0,0 +1,108 @@ +import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import sinon from 'sinon'; +import type SdExpandable from './expandable'; + +describe('', () => { + it('should be accessible', async () => { + const el = await fixture(html` + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. + + `); + + await expect(el).to.be.accessible(); + }); + + it('should be visible when the open attribute is set', async () => { + const el = await fixture(html` + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. + + `); + + expect(el.open).to.be.true; + expect(el.shadowRoot!.querySelector('details')?.getAttribute('open')).to.equal('true'); + }); + + it('should not be visible without the open attribute', async () => { + const el = await fixture(html` + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. + + `); + + expect(el.open).to.be.false; + expect(el.shadowRoot!.querySelector('details')?.getAttribute('open')).to.equal('false'); + }); + + it('should emit sd-show and sd-after-show when opened programmatically', async () => { + const el = await fixture(html` + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. + + `); + const showHandler = sinon.spy(); + const afterShowHandler = sinon.spy(); + + el.addEventListener('sd-show', showHandler); + el.addEventListener('sd-after-show', afterShowHandler); + + el.show(); // Programmatically open the expandable + await el.updateComplete; + + await waitUntil(() => showHandler.calledOnce); + await waitUntil(() => afterShowHandler.calledOnce); + + expect(showHandler).to.have.been.calledOnce; + expect(afterShowHandler).to.have.been.calledOnce; + expect(el.shadowRoot!.querySelector('details')?.getAttribute('open')).to.equal('true'); + }); + + it('should emit sd-hide and sd-after-hide when closed programmatically', async () => { + const el = await fixture(html` + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. + + `); + const hideHandler = sinon.spy(); + const afterHideHandler = sinon.spy(); + + el.addEventListener('sd-hide', hideHandler); + el.addEventListener('sd-after-hide', afterHideHandler); + el.hide(); // Programmatically close the expandable + + await waitUntil(() => hideHandler.calledOnce); + await waitUntil(() => afterHideHandler.calledOnce); + + expect(hideHandler).to.have.been.calledOnce; + expect(afterHideHandler).to.have.been.calledOnce; + expect(el.shadowRoot!.querySelector('details')?.getAttribute('open')).to.equal('false'); + }); + + it('should toggle open state when toggle button is clicked', async () => { + const el = await fixture(html` + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. + + `); + const toggleButton = el.shadowRoot?.querySelector('button'); + toggleButton?.click(); // Simulate user clicking the toggle button + + expect(el.open).to.be.true; + + toggleButton?.click(); // Click again to close + expect(el.open).to.be.false; + }); +}); diff --git a/packages/components/src/components/expandable/expandable.ts b/packages/components/src/components/expandable/expandable.ts new file mode 100644 index 000000000..bd02bfe25 --- /dev/null +++ b/packages/components/src/components/expandable/expandable.ts @@ -0,0 +1,220 @@ +import { css, html, unsafeCSS } from 'lit'; +import { customElement } from '../../../src/internal/register-custom-element'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query } from 'lit/decorators.js'; +import { waitForEvent } from '../../internal/event'; +import { watch } from '../../internal/watch.js'; +import componentStyles from '../../styles/component.styles'; +import cx from 'classix'; +import InteractiveStyles from '../../styles/interactive/interactive.css?inline'; +import SolidElement from '../../internal/solid-element'; + +/** + * @summary Expandable shows a brief summary and expands to show additional content. + * @documentation https://solid.union-investment.com/[storybook-link]/expandable + * @status stable + * @since 3.11.0 + * + * @dependency sd-icon + * + * @slot - Content of the expandable + * @slot toggle-open - Content of the toggle button when the expandable is open + * @slot toggle-closed - Content of the toggle button when the expandable is closed + * + * @event sd-show - Emitted when the expandable opens. + * @event sd-after-show - Emitted after the expandable opens and all animations are complete. + * @event sd-hide - Emitted when the expandable closes. + * @event sd-after-hide - Emitted after the expandable closes and all animations are complete. + * + * @csspart content - The content of the expandable. + * @csspart toggle - The toggle button of the expandable. + * @csspart summary - The summary of the expandable. + * @csspart details - The details element of the expandable. + * + * @cssproperty --gradient-color-start - Start color of the gradient. Set the opacity to 0 (default: rgba(255, 255, 255, 0)) + * @cssproperty --gradient-color-end - End color of the gradient. Set the opacity to 1 (default: rgba(255, 255, 255, 1)) + * @cssproperty --gradient-height - Height of the gradient (default: 24px) + * @cssproperty --component-expandable-max-block-size - Different value for initial visible block (default: 90px) + */ +@customElement('sd-expandable') +export default class SdExpandable extends SolidElement { + @query('.content-preview') contentPreview: HTMLElement; + @query('details') details: HTMLDetailsElement; + + /** + * Used to check whether the component is expanded or not. + */ + @property({ type: Boolean, reflect: true }) open = false; + + /** Inverts the expandable and sets the color of the gradient to primary. */ + @property({ type: Boolean, reflect: true }) inverted = false; + + public localize = new LocalizeController(this); + + private updateMaxHeight() { + const scrollHeight = this.contentPreview?.scrollHeight.toString(); + this.style.setProperty('--max-height-pixel', `${scrollHeight}px`); + this.style.setProperty('--max-height', scrollHeight); + } + + private onToggleClick() { + this.updateMaxHeight(); + this.open = !this.open; + } + + @watch('open', { waitUntilFirstUpdate: true }) + onOpenChange() { + if (this.open) { + this.emit('sd-show'); + this.updateComplete.then(() => { + this.emit('sd-after-show'); + }); + } else { + this.emit('sd-hide'); + this.updateComplete.then(() => { + this.emit('sd-after-hide'); + }); + } + + this.details.setAttribute('open', this.open.toString()); + } + + /** Opens the expandable */ + async show() { + if (this.open) { + return undefined; + } + + this.onToggleClick(); + return waitForEvent(this, 'sd-after-show'); + } + + /** Closes the expandable */ + async hide() { + if (!this.open) { + return undefined; + } + + this.onToggleClick(); + return waitForEvent(this, 'sd-after-hide'); + } + + render() { + return html` +
+ +
+ +
+
+ + `; + } + + firstUpdated() { + this.cloneContentToLightDOM(); + + // Set the initial state of the details element + this.details.setAttribute('open', this.open.toString()); + } + + cloneContentToLightDOM() { + const slot = document.createElement('div'); + slot.setAttribute('slot', 'clone'); + + const nodes = Array.from(this.childNodes); + nodes.forEach(node => { + const clone = node.cloneNode(true); + slot.appendChild(clone); + }); + + this.appendChild(slot); + } + + static styles = [ + SolidElement.styles, + unsafeCSS(InteractiveStyles), + componentStyles, + css` + :host { + --gradient-color-start: rgba(255, 255, 255, 0); + --gradient-color-end: rgba(255, 255, 255, 1); + --component-expandable-max-block-size: 90px; + --gradient-height: 24px; + --gradient: var(--gradient-color-start) 0%, var(--gradient-color-end) 80%, var(--gradient-color-end) 100%; + + @apply inline-block relative w-full; + } + + .toggle::-moz-focus-inner { + @apply border-none p-0; + } + + details > summary::-webkit-details-marker, + details[open] summary { + @apply hidden; + } + + summary { + max-block-size: var(--component-expandable-max-block-size); + } + + :host([open]) summary { + max-block-size: var(--max-height-pixel, 1000vh); + } + + .content { + max-block-size: var(--component-expandable-max-block-size); + } + + :host([open]) .content { + max-block-size: var(--max-height-pixel, 1000vh); + } + + :host(:not([open])) .content::after { + @apply absolute bottom-0 left-0 block w-full; + content: ' '; + + height: var(--gradient-height); + background: linear-gradient(180deg, var(--gradient)); + } + + :host([inverted]:not([open])) .content::after { + background: var( + --gradient-vertical-transparent-primary, + linear-gradient(180deg, rgba(0, 53, 142, 0) 0%, rgba(0, 53, 142, 1) 80%, rgba(0, 53, 142, 1) 100%) + ); + } + ` + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'sd-expandable': SdExpandable; + } +} diff --git a/packages/components/src/docs/Migration/ui-expandable.mdx b/packages/components/src/docs/Migration/ui-expandable.mdx new file mode 100644 index 000000000..eb79ec1d9 --- /dev/null +++ b/packages/components/src/docs/Migration/ui-expandable.mdx @@ -0,0 +1,71 @@ +# Migration Guide: From `ui-expandable` to `sd-expandable` + +The new `sd-expandable` is designed to replace the `ui-expandable`. Instead of mainly providing content via attributes, the `sd-expandable` component uses slots to allow for more flexibility and customization. + +## ⚙️ Attributes + +### ✨ New Attributes + +#### [inverted] + +Inverts the expandable and sets the color of the gradient to primary. + +
+ +## ✍️ CSS Variables + +### ✨ New CSS Variables + +#### [--gradient-color-start] + +Start color of the gradient. The starting gradient should ideally have an opacity of 0 (default: rgba(255, 255, 255, 0)) + +#### [--gradient-color-end] + +End color of the gradient. The ending gradient should ideally have an opacity of 1 (default: rgba(255, 255, 255, 1)) + +#### [--gradient-height] + +Height of the gradient (default: 24px) + +### ❌ Removed CSS variables + +The following CSS variables have been removed from the new sd-expandable component: + +1. [--ui-animation-bezier] + +
+ +## 🥳 Events + +### ✨ New Events + +#### [sd-show] + +Fires when the expandable is shown. + +#### [sd-after-show] + +Fires after the expandable is shown. + +#### [sd-hide] + +Fires when the expandable is hidden. + +#### [sd-after-hide] + +Fires after the expandable is hidden. + +
+ +## 🧪 Methods + +### ✨ New Methods + +#### [show] + +Opens the expandable. + +#### [hide] + +Closes the expandable. diff --git a/packages/components/src/translations/de.ts b/packages/components/src/translations/de.ts index 8141f4aca..8a3358831 100644 --- a/packages/components/src/translations/de.ts +++ b/packages/components/src/translations/de.ts @@ -29,7 +29,9 @@ const translation: Translation = { selectDefaultPlaceholder: 'Bitte auswählen', showPassword: 'Passwort anzeigen', slideNum: slide => `Folie ${slide}`, - toggleColorFormat: 'Farbformat umschalten' + toggleColorFormat: 'Farbformat umschalten', + showMore: 'Mehr anzeigen', + showLess: 'Weniger anzeigen' }; registerTranslation(translation); diff --git a/packages/components/src/translations/en.ts b/packages/components/src/translations/en.ts index f51301ada..c04a5da80 100644 --- a/packages/components/src/translations/en.ts +++ b/packages/components/src/translations/en.ts @@ -29,7 +29,9 @@ const translation: Translation = { nextSlide: 'Next slide', previousSlide: 'Previous slide', goToSlide: (slide, count) => `Go to slide ${slide} of ${count}`, - slideNum: num => `Slide ${num}` + slideNum: num => `Slide ${num}`, + showMore: 'Show more', + showLess: 'Show less' }; registerTranslation(translation); diff --git a/packages/components/src/utilities/localize.ts b/packages/components/src/utilities/localize.ts index dd86f34ed..e962785f3 100644 --- a/packages/components/src/utilities/localize.ts +++ b/packages/components/src/utilities/localize.ts @@ -95,4 +95,6 @@ export interface Translation extends DefaultTranslation { showPassword: string; slideNum: (slide: number) => string; toggleColorFormat: string; + showMore: string; + showLess: string; }