diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 6b2393ba..a3365520 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -11,6 +11,8 @@ module.exports = { "deps", "deps-dev", // Components as scopes listed below + "accordion", + "accordion-group", "button", "icon", "input", diff --git a/src/baklava.ts b/src/baklava.ts index d2fec698..1d9c0f41 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -1,3 +1,5 @@ +export { default as BlAccordionGroup } from "./components/accordion-group/bl-accordion-group"; +export { default as BlAccordion } from "./components/accordion-group/accordion/bl-accordion"; export { default as BlAlert } from "./components/alert/bl-alert"; export { default as BlBadge } from "./components/badge/bl-badge"; export { default as BlButton } from "./components/button/bl-button"; diff --git a/src/components/accordion-group/accordion/bl-accordion.css b/src/components/accordion-group/accordion/bl-accordion.css new file mode 100644 index 00000000..e018f464 --- /dev/null +++ b/src/components/accordion-group/accordion/bl-accordion.css @@ -0,0 +1,81 @@ +:host { + display: block; +} + +.accordion { + --border: 1px solid var(--bl-color-neutral-lighter); + --default-radius: var(--bl-size-2xs); + --radius-top-left: var(--bl-accordion-radius-top-left, var(--default-radius)); + --radius-top-right: var(--bl-accordion-radius-top-right, var(--default-radius)); + --radius-bottom-right: var(--bl-accordion-radius-bottom-right, var(--default-radius)); + --radius-bottom-left: var(--bl-accordion-radius-bottom-left, var(--default-radius)); + + width: 100%; +} + +.summary { + list-style: none; + user-select: none; + cursor: pointer; + font: var(--bl-font-title-3-medium); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bl-size-2xs); + padding: var(--bl-size-m); + background: var(--bl-color-neutral-full); + color: var(--bl-color-neutral-darker); + border: var(--border); + border-bottom: var(--bl-accordion-border-bottom, var(--border)); + border-radius: var(--radius-top-left) var(--radius-top-right) var(--radius-bottom-right) + var(--radius-bottom-left); + transition: background-color 200ms; +} + +.summary::-webkit-details-marker { + display: none; +} + +.summary:hover { + background: var(--bl-color-neutral-lightest); +} + +.summary:focus-visible { + outline: 2px solid var(--bl-color-primary); + outline-offset: -1px; +} + +.indicator { + transition: transform 200ms; +} + +.accordion[open] .indicator { + transform: rotate(180deg); +} + +.accordion[open] .summary { + border-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.caption { + flex: 1; +} + +.accordion-content { + padding: var(--bl-size-m); + background: var(--bl-color-neutral-full); + border: var(--border); + border-top: 0; + border-bottom: var(--bl-accordion-border-bottom, var(--border)); + border-bottom-left-radius: var(--radius-bottom-left); + border-bottom-right-radius: var(--radius-bottom-right); + font: var(--bl-font-body-text-2-regular); +} + +.disabled .summary { + cursor: not-allowed; + background: var(--bl-color-neutral-lightest); + color: var(--bl-color-neutral-light); +} diff --git a/src/components/accordion-group/accordion/bl-accordion.stories.mdx b/src/components/accordion-group/accordion/bl-accordion.stories.mdx new file mode 100644 index 00000000..196c0e65 --- /dev/null +++ b/src/components/accordion-group/accordion/bl-accordion.stories.mdx @@ -0,0 +1,92 @@ +import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs"; +import { html } from "lit"; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; + + + +export const AccordionTemplate = (args) => html` + ${unsafeHTML(args.content)} + `; + +# Accordion + +[ADR](https://github.com/Trendyol/baklava/issues/739) +[Figma](https://www.figma.com/file/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?type=design&node-id=15548%3A20910) + +Accordion is a component that allows the user to show and hide sections of related content on a page. + +### Usage + + +* The accordion component includes a free content area. +* The caption can be set either via **attribute** or **slot**. +* An icon can be added to the beginning of the caption. +* Accordion can be disabled. +* The accordion group component combines accordions and only allows one accordion to be open by default + + + + {AccordionTemplate.bind({})} + + + + +## With Icon + +Add icon to beginning of caption with `icon` attribute. + + + + {AccordionTemplate.bind({})} + + + +## Disabled + +Use the `disable` attribute to prevent the details from expanding. + + + + {AccordionTemplate.bind({})} + + + +## Reference + + + +## Public Functions + +* `expand()`: Can be used to expand accordion. +* `collapse()`: Can be used to collapse accordion. + +Example usage; + +```js +document.querySelector('bl-accordion').expand(); +document.querySelector('bl-accordion').collapse(); +``` diff --git a/src/components/accordion-group/accordion/bl-accordion.test.ts b/src/components/accordion-group/accordion/bl-accordion.test.ts new file mode 100644 index 00000000..da2f5cba --- /dev/null +++ b/src/components/accordion-group/accordion/bl-accordion.test.ts @@ -0,0 +1,184 @@ +import { assert, fixture, html, expect, waitUntil, aTimeout } from "@open-wc/testing"; +import { spy } from "sinon"; +import BlAccordion from "./bl-accordion"; + +describe("bl-accordion", () => { + it("is defined", () => { + const el = document.createElement("bl-accordion"); + + assert.instanceOf(el, BlAccordion); + }); + + it("renders with default values", async () => { + const el = await fixture(html` + `); + + assert.shadowDom.equal( + el, + `
+ +
+ + +
+
` + ); + }); + + it("should set icon", async () => { + const el = await fixture(html``); + + await el.updateComplete; + const iconEl = el.shadowRoot!.querySelector(".icon")!; + + expect(iconEl.getAttribute("name")).to.eq("eye_on"); + }); + + it("should set info icon when icon is boolean", async () => { + const el = await fixture(html``); + + const iconEl = el.shadowRoot!.querySelector(".icon")!; + + expect(iconEl.getAttribute("name")).to.eq("info"); + }); + + it("should set caption", async () => { + const captionText = "Best caption"; + const el = await fixture(html``); + const captionEl = el.shadowRoot?.querySelector(".caption"); + + expect(captionEl).to.exist; + expect(captionEl!.innerText).to.eq(captionText); + }); + + it("should set caption via slot", async () => { + const captionText = "Best caption"; + const el = await fixture(html`
${captionText}
`); + const captionSlot = el.shadowRoot!.querySelector('slot[name="caption"]'); + const captionSlotContent = captionSlot!.assignedNodes()[0] as HTMLDivElement; + + expect(captionSlotContent.innerText).to.eq(captionText); + }); + + it("should be visible with 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. Molestie at elementum eu facilisis. Morbi quis commodo odio aenean sed adipiscing diam + donec. + `); + + const body = el.shadowRoot!.querySelector(".accordion-content")!; + + expect(body.clientHeight).greaterThan(0); + }); + + 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. Molestie at elementum eu facilisis. Morbi quis commodo odio aenean sed adipiscing diam + donec. + `); + + const body = el.shadowRoot!.querySelector(".accordion-content")!; + + expect(body.clientHeight).to.eq(0); + }); + + it("should emit bl-toggle when click on collapsed accordion", 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. Molestie at elementum eu facilisis. Morbi quis commodo odio aenean sed adipiscing diam + donec. + `); + + const toggleHandler = spy(); + + el.addEventListener("bl-toggle", (e) => toggleHandler((e as CustomEvent).detail)); + + const summary = el.shadowRoot!.querySelector("summary")!; + + summary.click(); + + await waitUntil(() => toggleHandler.calledOnce); + + expect(toggleHandler).to.have.been.calledWith(true); + }); + + it("should emit bl-toggle when click on expanded accordion", 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. Molestie at elementum eu facilisis. Morbi quis commodo odio aenean sed adipiscing diam + donec. + `); + + const toggleHandler = spy(); + + el.addEventListener("bl-toggle", (e) => toggleHandler((e as CustomEvent).detail)); + + const summary = el.shadowRoot!.querySelector("summary")!; + + summary.click(); + + await waitUntil(() => toggleHandler.calledOnce); + + expect(toggleHandler).to.have.been.calledWith(false); + }); + + it("should not open when disabled", 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. Molestie at elementum eu facilisis. Morbi quis commodo odio aenean sed adipiscing diam + donec. + `); + + const summary = el.shadowRoot!.querySelector("summary")!; + + summary.click(); + + expect(el.open).to.eq(false); + }); + + it("should not change open attribute when disabled", 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. Molestie at elementum eu facilisis. Morbi quis commodo odio aenean sed adipiscing diam + donec. + `); + + expect(el.open).to.eq(false); + }); + + it("should not affect animation", 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. Molestie at elementum eu facilisis. Morbi quis commodo odio aenean sed adipiscing diam + donec. + `); + + const summary = el.shadowRoot!.querySelector("summary")!; + + summary.click(); + summary.click(); + summary.click(); + + await aTimeout(200); + expect(el.open).to.eq(true); + }); +}); diff --git a/src/components/accordion-group/accordion/bl-accordion.ts b/src/components/accordion-group/accordion/bl-accordion.ts new file mode 100644 index 00000000..0e700e37 --- /dev/null +++ b/src/components/accordion-group/accordion/bl-accordion.ts @@ -0,0 +1,165 @@ +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { CSSResultGroup } from "lit/development"; +import { classMap } from "lit/directives/class-map.js"; +import { event, EventDispatcher } from "../../../utilities/event"; +import { stringBooleanConverter } from "../../../utilities/string-boolean.converter"; +import "../../icon/bl-icon"; +import { BaklavaIcon } from "../../icon/icon-list"; +import style from "./bl-accordion.css"; + +enum AnimationStatus { + EXPANDING, + COLLAPSING, +} + +@customElement("bl-accordion") +export default class BlAccordion extends LitElement { + /** + * Whether the accordion is expanded + */ + @property({ reflect: true, type: Boolean }) + open = false; + + /** + * Sets accordion caption. + */ + @property({ reflect: true }) + caption?: string; + + /** + * Add icon to beginning of the title + */ + @property({ converter: stringBooleanConverter() }) + icon?: boolean | BaklavaIcon; + + /** + * Whether the accordion is disabled + */ + @property({ reflect: true, type: Boolean }) + disabled = false; + + /** + * Fires when accordion open state change. + */ + @event("bl-toggle") private _onToggle: EventDispatcher; + + @property({ type: Number }) + animationDuration = 250; + + private _animation: Animation | null; + private _animationStatus: AnimationStatus | null = null; + + @query("details") + detailsEl: HTMLDetailsElement; + + @query("summary") + summaryEl: HTMLElement; + + @query(".accordion-content") + contentEl: HTMLElement; + + static get styles(): CSSResultGroup { + return [style]; + } + + _animate(isExpanding: boolean) { + this._animationStatus = isExpanding ? AnimationStatus.EXPANDING : AnimationStatus.COLLAPSING; + + const startHeight = `${this.detailsEl.offsetHeight}px`; + const endHeight = isExpanding + ? `${this.summaryEl.offsetHeight + this.contentEl.offsetHeight}px` + : `${this.summaryEl.offsetHeight}px`; + + if (this._animation) { + this._animation.cancel(); + } + + this._animation = this.detailsEl.animate( + { + height: [startHeight, endHeight], + }, + { + duration: this.animationDuration, + easing: "ease-out", + } + ); + + this._animation.onfinish = () => this._onAnimationFinish(isExpanding); + this._animation.oncancel = () => (this._animationStatus = null); + } + + private _onAnimationFinish(open: boolean) { + this.open = open; + this._animation = null; + this._animationStatus = null; + this.detailsEl.style.height = this.detailsEl.style.overflow = ""; + } + + expand() { + this.detailsEl.style.overflow = "hidden"; + this.detailsEl.style.height = `${this.detailsEl.offsetHeight}px`; + this.open = true; + this._animate(true); + } + + collapse() { + this._animate(false); + } + + private _clickHandler(e: Event) { + e.preventDefault(); + + if (this.disabled) return; + + if (this._animationStatus === AnimationStatus.COLLAPSING || !this.open) { + this.expand(); + } else if (this._animationStatus === AnimationStatus.EXPANDING || this.open) { + this.collapse(); + } + } + + protected updated(_changedProperties: PropertyValues) { + if (_changedProperties.has("open")) { + if (this.disabled && this.open) { + this._onAnimationFinish(false); + return; + } + + this._onToggle(this.open); + } + } + + render(): TemplateResult { + const icon = this.icon + ? html`` + : null; + + return html`
+ + ${icon} + + ${this.caption} + + + + +
+ +
+
`; + } +} diff --git a/src/components/accordion-group/bl-accordion-group.css b/src/components/accordion-group/bl-accordion-group.css new file mode 100644 index 00000000..5dc5926b --- /dev/null +++ b/src/components/accordion-group/bl-accordion-group.css @@ -0,0 +1,24 @@ +.accordion-group { + display: flex; + flex-direction: column; + gap: 0; +} + +.accordion-group ::slotted(bl-accordion:first-child) { + --bl-accordion-radius-bottom-right: 0; + --bl-accordion-radius-bottom-left: 0; + --bl-accordion-border-bottom: 0; +} + +.accordion-group ::slotted(bl-accordion:not(:last-child, :first-child)) { + --bl-accordion-radius-bottom-right: 0; + --bl-accordion-radius-bottom-left: 0; + --bl-accordion-radius-top-left: 0; + --bl-accordion-radius-top-right: 0; + --bl-accordion-border-bottom: 0; +} + +.accordion-group ::slotted(bl-accordion:last-child) { + --bl-accordion-radius-top-right: 0; + --bl-accordion-radius-top-left: 0; +} diff --git a/src/components/accordion-group/bl-accordion-group.stories.mdx b/src/components/accordion-group/bl-accordion-group.stories.mdx new file mode 100644 index 00000000..a14f1d78 --- /dev/null +++ b/src/components/accordion-group/bl-accordion-group.stories.mdx @@ -0,0 +1,44 @@ +import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { html } from "lit"; + + + +export const AccordionTemplate = (args) => html` + ${args.accordions.map((a) => html` + ${a.content}`)} + `; + +# Accordion Group + +[ADR](https://github.com/Trendyol/baklava/issues/739) +[Figma](https://www.figma.com/file/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?type=design&node-id=15548%3A20910) + +The Accordion Group component combines accordions and only allows one accordion to be open by default + + + + {AccordionTemplate.bind({})} + + + +## Allow Multiple + +Allow multiple accordions to be open at once with `multiple` attribute. + + + + {AccordionTemplate.bind({})} + + + +## Reference + + diff --git a/src/components/accordion-group/bl-accordion-group.test.ts b/src/components/accordion-group/bl-accordion-group.test.ts new file mode 100644 index 00000000..3bd970fe --- /dev/null +++ b/src/components/accordion-group/bl-accordion-group.test.ts @@ -0,0 +1,43 @@ +import { assert, aTimeout, expect, fixture, html } from "@open-wc/testing"; +import BlAccordionGroup from "./bl-accordion-group"; +import "./accordion/bl-accordion"; +import BlAccordion from "./accordion/bl-accordion"; + +describe("bl-accordion-group", () => { + it("is defined", () => { + const el = document.createElement("bl-accordion-group"); + + assert.instanceOf(el, BlAccordionGroup); + }); + + it("renders with default values", async () => { + const el = await fixture(html` + `); + + assert.shadowDom.equal(el, "
"); + }); + + it("should open only one accordion", async () => { + const el = await fixture(html` + + + + + `); + + await el.updateComplete; + + const accordions = el.querySelectorAll("bl-accordion")!; + + for (const accordion of accordions) { + accordion.shadowRoot!.querySelector("summary")!.click(); + await aTimeout(250); + } + + await aTimeout(250); + + const openAccordions = el.querySelectorAll("bl-accordion[open]"); + + expect(openAccordions.length).to.eq(1); + }); +}); diff --git a/src/components/accordion-group/bl-accordion-group.ts b/src/components/accordion-group/bl-accordion-group.ts new file mode 100644 index 00000000..05739bb9 --- /dev/null +++ b/src/components/accordion-group/bl-accordion-group.ts @@ -0,0 +1,39 @@ +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property, queryAssignedElements } from "lit/decorators.js"; +import { CSSResultGroup } from "lit/development"; +import { BlAccordion } from "../../baklava"; +import style from "./bl-accordion-group.css"; + +@customElement("bl-accordion-group") +export default class BlAccordionGroup extends LitElement { + /** + * Allow multiple accordions to be open at once + */ + @property({ reflect: true, type: Boolean }) + multiple = false; + + @queryAssignedElements({ selector: "bl-accordion" }) + accordions: BlAccordion[]; + + static get styles(): CSSResultGroup { + return [style]; + } + + handleToggleAccordions(e: CustomEvent) { + const target = e.target as BlAccordion; + + if (!this.multiple && e.detail) { + this.accordions.forEach(a => { + if (target !== a) { + a.collapse(); + } + }); + } + } + + render(): TemplateResult { + return html`
+ +
`; + } +}