diff --git a/commitlint.config.js b/commitlint.config.js index b5b9519b..78db2a4f 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -23,6 +23,7 @@ module.exports = { 'radio', 'dialog', 'drawer', + 'dropdown', ], ], }, diff --git a/src/baklava.ts b/src/baklava.ts index 8317dde3..2ec07140 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -17,4 +17,7 @@ export { default as BlTab } from './components/tab-group/tab/bl-tab'; export { default as BlTabGroup } from './components/tab-group/bl-tab-group'; export { default as BlTabPanel } from './components/tab-group/tab-panel/bl-tab-panel'; export { default as BlTooltip } from './components/tooltip/bl-tooltip'; +export { default as BlDropdown } from './components/dropdown/bl-dropdown'; +export { default as BlDropdownItem } from './components/dropdown/item/bl-dropdown-item'; +export { default as BlDropdownGroup } from './components/dropdown/group/bl-dropdown-group'; export { getIconPath, setIconPath } from './utilities/asset-paths'; diff --git a/src/components/button/bl-button.css b/src/components/button/bl-button.css index 357198d9..e24bf3f3 100644 --- a/src/components/button/bl-button.css +++ b/src/components/button/bl-button.css @@ -20,7 +20,7 @@ display: flex; gap: var(--margin-icon); - justify-content: center; + justify-content: var(--bl-button-justify, 'center'); align-items: center; box-sizing: border-box; width:100%; @@ -118,7 +118,7 @@ } :host([variant='tertiary'][disabled]) .button { - --main-color:transparent; + --main-color: transparent; } :host([variant='secondary']:hover:not([disabled])) .button { @@ -130,3 +130,31 @@ --content-color: var(--main-hover-color); --bg-color: var(--text-hover-color); } + +:host([dropdown]) .open { + display: none; +} + +:host([dropdown]) .active .open { + display: inline-block; +} + +:host([dropdown]) .active .close { + display: none; +} + +:host .active.button { + --bg-color: var(--main-hover-color); + --border-color: var(--main-hover-color); +} + +:host([variant='secondary']) .active.button { + --content-color: var(--bl-color-content-primary-contrast); + --bg-color: var(--main-hover-color); +} + +:host([variant='tertiary']) .active.button { + --content-color: var(--main-color); + --bg-color: var(--bl-color-tertiary); + --border-color: transparent; +} diff --git a/src/components/button/bl-button.ts b/src/components/button/bl-button.ts index f15fde44..7911d707 100644 --- a/src/components/button/bl-button.ts +++ b/src/components/button/bl-button.ts @@ -1,5 +1,5 @@ import { CSSResultGroup, html, LitElement, TemplateResult } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state, query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { event, EventDispatcher } from '../../utilities/event'; @@ -78,11 +78,43 @@ export default class BlButton extends LitElement { @property({ type: String }) type: 'submit' | null; + /** + * Sets button type to dropdown + */ + @property({ type: Boolean }) + dropdown = false; + + /** + * Active state + */ + @state({}) + active = false; + + @query('.button') + private button: HTMLAnchorElement | HTMLButtonElement; + /** * Fires when button clicked */ @event('bl-click') private onClick: EventDispatcher; + private get _isActive() { + return this.active; + } + + private caretTemplate(): TemplateResult { + return html` + `; + } + + private _handleClick() { + this.onClick('Click event fired!'); + } + + focus() { + this.button.focus(); + } + get _hasIconSlot() { return this.querySelector(':scope > [slot="icon"]') !== null; } @@ -109,10 +141,12 @@ export default class BlButton extends LitElement { const isAnchor = !!this.href; const icon = this.icon ? html`` : ''; const slots = html`${icon} `; + const caret = this.dropdown ? this.caretTemplate() : ''; const classes = classMap({ 'button': true, 'has-icon': this.icon || this._hasIconSlot, 'has-content': this._hasDefaultSlot, + 'active': !isAnchor && this._isActive, }); return isAnchor @@ -123,8 +157,8 @@ export default class BlButton extends LitElement { href=${ifDefined(this.href)} target=${ifDefined(this.target)} role="button" - >${slots}` + >${slots} + ` : html``; } - - private _handleClick() { - this.onClick('Click event fired!'); - } } declare global { diff --git a/src/components/dropdown/bl-dropdown.css b/src/components/dropdown/bl-dropdown.css new file mode 100644 index 00000000..0c926601 --- /dev/null +++ b/src/components/dropdown/bl-dropdown.css @@ -0,0 +1,46 @@ +:host { + position: relative; + display: inline-block; +} + +.popover { + --left: 0; + --top: 0; + --border-color: var(--bl-color-primary); + + position: fixed; + + /* FIXME: Use z-index variable */ + z-index: 1; + display: none; + flex-direction: column; + align-items: flex-start; + padding: var(--bl-size-m); + gap: var(--bl-size-xs); + overflow-y: auto; + background: var(--bl-color-primary-background); + border: 1px solid var(--border-color); + + /* FIXME: Use variables */ + box-shadow: 0 10px 15px -8px #27314226; + border-radius: var(--bl-size-3xs); + left: var(--left); + top: var(--top); + box-sizing: border-box; +} + +:host([kind='neutral']) .popover { + --border-color: var(--bl-color-secondary); +} + +:host([kind='success']) .popover { + --border-color: var(--bl-color-success); +} + +:host([kind='danger']) .popover { + --border-color: var(--bl-color-danger); +} + +.visible { + display: flex; +} diff --git a/src/components/dropdown/bl-dropdown.stories.mdx b/src/components/dropdown/bl-dropdown.stories.mdx new file mode 100644 index 00000000..38fdb349 --- /dev/null +++ b/src/components/dropdown/bl-dropdown.stories.mdx @@ -0,0 +1,147 @@ +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { Meta, Canvas, ArgsTable, Story, Preview, Source } from '@storybook/addon-docs'; +import { userEvent } from '@storybook/testing-library'; + + + +export const dropdownOpener = async ({ canvasElement }) => { + const dropdown = canvasElement?.querySelector('bl-dropdown') + if(dropdown.shadowRoot) { + const button = dropdown.shadowRoot.querySelector('bl-button') + await userEvent.click(button); + } +} + +export const SingleDropdownButtonTemplate = (args) => html` + ${args.content || 'Action 1'} + Action 2 + + Action 3 + Action 4 + Action 5 + + Action 6 + Action 7 + + ` + +export const Template = (args) => html` +${SingleDropdownButtonTemplate({...args})} +${SingleDropdownButtonTemplate({variant: 'secondary', ...args})} +${SingleDropdownButtonTemplate({variant: 'tertiary', ...args})}` + +export const ButtonTypes = (args) => html` +${SingleDropdownButtonTemplate({...args})} +${SingleDropdownButtonTemplate({kind: 'neutral', ...args})} +${SingleDropdownButtonTemplate({kind: 'success', ...args})} +${SingleDropdownButtonTemplate({kind: 'danger', ...args})}` + +export const SizesTemplate = (args) => html` +${SingleDropdownButtonTemplate({size: 'large', ...args})} +${SingleDropdownButtonTemplate({size: 'medium', ...args})} +${SingleDropdownButtonTemplate({size: 'small', ...args})}` + +# Dropdown Button + +Dropdown Button is used to display a list of actions. + +Inline styles in examples are only for **demo purposes**. Use regular CSS classes or tag selectors to set styles. + +## Dropdown Button Variants + +Dropdown Button has the same variants ([Primary](/docs/components-button--primary-buttons), [Secondary](/docs/components-button--secondary-buttons) and [Tertiary](/docs/components-button--tertiary-buttons)) with the [Button](/docs/components-button--variants). +Every variant represents the importance of the actions inside it. + + + + {Template.bind({})} + + + +## Dropdown Button Kinds + +Dropdown Button has the same kinds as the button has. +Every kind indicates a state of the dropdown buttons. It can has 4 different "kind"s. `default`, `neutral`, `success` and `danger`. + + + + {ButtonTypes.bind({})} + + + +## Dropdown Button Sizes + +We have 3 sizes of dropdown buttons: **Large**, **Medium**, **Small**. Default size is **Medium**. + + + + {SizesTemplate.bind({})} + + + +If dropdown button has an action with a long text that can not fit in a single line, popover will be automatically widen to the right side of the dropdown button. + + + + {SingleDropdownButtonTemplate.bind({})} + + + +## Disabling Dropdown Buttons + +We have 2 types of disabled dropdown buttons: Disable version of Primary and Secondary buttons is the same. + + + + {SizesTemplate.bind({})} + + + +Whereas Tertiary buttons keep their transparent backgrounds. + + + + {SizesTemplate.bind({})} + + + + +## Reference + + diff --git a/src/components/dropdown/bl-dropdown.test.ts b/src/components/dropdown/bl-dropdown.test.ts new file mode 100644 index 00000000..bab92aa4 --- /dev/null +++ b/src/components/dropdown/bl-dropdown.test.ts @@ -0,0 +1,246 @@ +import BlDropdown from './bl-dropdown'; +import { assert, fixture, html, oneEvent, expect, elementUpdated, waitUntil } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; + +import type typeOfBlDropdown from './bl-dropdown'; +import BlButton from '../button/bl-button'; + +describe('bl-dropdown', () => { + it('is defined', () => { + const el = document.createElement('bl-dropdown'); + assert.instanceOf(el, BlDropdown); + }); + + it('should render with the default values', async () => { + const el = await fixture(html``); + assert.shadowDom.equal( + el, + ` + + Dropdown Button + + + ` + ); + }); + + it('should open dropdown', async () => { + const el = await fixture(html``); + + const buttonHost = el.shadowRoot?.querySelector('bl-button'); + const button = buttonHost.shadowRoot?.querySelector('.button') as HTMLElement | null; + + button?.click(); + + expect(el.opened).to.true; + }); + + it('should close dropdown', async () => { + const el = await fixture(html``); + + const buttonHost = el.shadowRoot?.querySelector('bl-button'); + const button = buttonHost.shadowRoot?.querySelector('.button') as HTMLElement | null; + + button?.click(); + expect(el.opened).to.true; + + button?.click(); + expect(el.opened).to.false; + }); + + it('should close dropdown when click outside', async () => { + const el = await fixture(html` + + `); + + const buttonHost = el.shadowRoot?.querySelector('bl-button'); + const button = buttonHost.shadowRoot?.querySelector('.button') as HTMLElement | null; + + button?.click(); + expect(el.opened).to.true; + + const body = el.closest('body'); + body.click(); + + setTimeout(() => { + expect(el.opened).to.false; + }); + }); + + it('should fire event when dropdown opened', async () => { + const el = await fixture(html``); + + const buttonHost = el.shadowRoot?.querySelector('bl-button'); + const button = buttonHost.shadowRoot?.querySelector('.button') as HTMLElement | null; + + setTimeout(() => button?.click()); + const event = await oneEvent(el, 'bl-dropdown-open'); + + expect(el).to.exist; + expect(event).to.exist; + expect(event.detail).to.be.equal('Dropdown opened!'); + + expect(el.opened).to.true; + }); + + it('should fire event when dropdown closed', async () => { + const el = await fixture(html``); + + const buttonHost = el.shadowRoot?.querySelector('bl-button'); + const button = buttonHost.shadowRoot?.querySelector('.button') as HTMLElement | null; + + button?.click(); + + setTimeout(() => button?.click()); + const event = await oneEvent(el, 'bl-dropdown-close'); + + expect(el).to.exist; + expect(event).to.exist; + expect(event.detail).to.be.equal('Dropdown closed!'); + }); + + describe('keyboard navigation', () => { + it('should focus next action with down arrow key', async () => { + + //when + const el = await fixture( + html`
+ Action 1 + Action 2 + Action 3 +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + await waitUntil( + () => el.querySelector('bl-dropdown'), + 'Element did not render children', + ); + + const dropdown = el.querySelector('bl-dropdown'); + + const tabKey = navigator.userAgent.includes('Safari') && + !navigator.userAgent.includes('HeadlessChrome') + ? 'Alt+Tab' : 'Tab' + + //given + await sendKeys({ + press: tabKey, + }); + await sendKeys({ + press: 'Enter', + }); + await sendKeys({ + press: 'ArrowDown', + }); + + //then + expect(document.activeElement).to.equal(dropdown?.options[0]); + }); + + it('should focus previous action with up arrow key', async () => { + + //when + const el = await fixture( + html`
+ Action 1 + Action 2 + Action 3 +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + await waitUntil( + () => el.querySelector('bl-dropdown'), + 'Element did not render children', + ); + + const dropdown = el.querySelector('bl-dropdown'); + + const tabKey = navigator.userAgent.includes('Safari') && + !navigator.userAgent.includes('HeadlessChrome') + ? 'Alt+Tab' : 'Tab' + + //given + await sendKeys({ + press: tabKey, + }); + + await sendKeys({ + press: 'Enter', + }); + + await sendKeys({ + press: 'ArrowDown', + }); + + await sendKeys({ + press: 'ArrowDown', + }); + + await sendKeys({ + down: 'ArrowUp', + }); + + //then + expect(document.activeElement).to.equal(dropdown?.options[0]); + }); + + it('should close dropdown with escape key', async () => { + //when + const el = await fixture( + html`
+ Action 1 + Action 2 + Action 3 +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + await waitUntil( + () => el.querySelector('bl-dropdown'), + 'Element did not render children', + ); + + const dropdown = el.querySelector('bl-dropdown'); + + const tabKey = navigator.userAgent.includes('Safari') && + !navigator.userAgent.includes('HeadlessChrome') + ? 'Alt+Tab' : 'Tab' + + //given + await sendKeys({ + press: tabKey, + }); + await sendKeys({ + press: 'Enter', + }); + + //then + expect(dropdown?.opened).to.equal(true); + + //given + await sendKeys({ + press: 'Escape', + }); + + //then + expect(dropdown?.opened).to.equal(false); + }); + }); +}); diff --git a/src/components/dropdown/bl-dropdown.ts b/src/components/dropdown/bl-dropdown.ts new file mode 100644 index 00000000..9140a952 --- /dev/null +++ b/src/components/dropdown/bl-dropdown.ts @@ -0,0 +1,210 @@ +import { LitElement, html, CSSResultGroup, TemplateResult } from 'lit'; +import { customElement, property, state, query } from 'lit/decorators.js'; +import { + computePosition, + flip, + offset, + autoUpdate, + size, + MiddlewareArguments, +} from '@floating-ui/dom'; +import { event, EventDispatcher } from '../../utilities/event'; +import { classMap } from 'lit/directives/class-map.js'; + +import style from './bl-dropdown.css'; + +import '../button/bl-button'; +import { ButtonSize, ButtonVariant, ButtonKind } from '../button/bl-button'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import BlDropdownItem, { blDropdownItemTag } from './item/bl-dropdown-item'; + +export type CleanUpFunction = () => void; + +export const blDropdownTag = 'bl-dropdown'; + +/** + * @tag bl-dropdown + * @summary Baklava Dropdown component + */ +@customElement(blDropdownTag) +export default class BlDropdown extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + @query('bl-button') + private _dropdownButton: HTMLElement; + + @query('.popover') + private _popover: HTMLElement; + + private _cleanUpPopover: CleanUpFunction | null = null; + + @state() private _isPopoverOpen = false; + + /** + * Sets the dropdown button label + */ + @property({ type: String, reflect: true }) + label = 'Dropdown Button'; + + /** + * Sets the dropdown button variant + */ + @property({ type: String, reflect: true }) + variant: ButtonVariant = 'primary'; + + /** + * Sets the dropdown button kind + */ + @property({ type: String, reflect: true }) + kind: ButtonKind = 'default'; + + /** + * Sets the dropdown button size + */ + @property({ type: String, reflect: true }) + size: ButtonSize = 'medium'; + + /** + * Sets button as disabled + */ + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Fires when dropdown opened + */ + @event('bl-dropdown-open') private onOpen: EventDispatcher; + + /** + * Fires when dropdown closed + */ + @event('bl-dropdown-close') private onClose: EventDispatcher; + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('keydown', this.handleKeyDown); + } + disconnectedCallback() { + super.disconnectedCallback(); + + this._cleanUpPopover && this._cleanUpPopover(); + this.removeEventListener('keydown', this.handleKeyDown); + } + + get opened() { + return this._isPopoverOpen; + } + + private _handleClick() { + !this._isPopoverOpen && !this.disabled ? this.open() : this.close(); + } + + private _handleClickOutside = (event: MouseEvent) => { + const eventPath = event.composedPath() as HTMLElement[]; + if (!eventPath.includes(this._popover) && !eventPath.includes(this._dropdownButton)) { + this.close(); + } + }; + + private _setupPopover() { + this._cleanUpPopover = autoUpdate(this._dropdownButton, this._popover, () => { + computePosition(this._dropdownButton, this._popover, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: [ + flip(), + offset(8), + size({ + apply(args: MiddlewareArguments) { + Object.assign(args.elements.floating.style, { + minWidth: `${args.elements.reference.getBoundingClientRect().width}px`, + }); + }, + }), + ], + }).then(({ x, y }) => { + this._popover.style.setProperty('--left', `${x}px`); + this._popover.style.setProperty('--top', `${y}px`); + }); + }); + } + + private focusedOptionIndex = -1; + + private handleKeyDown(event: KeyboardEvent) { + // Next action + if (['ArrowDown', 'ArrowRight'].includes(event.key)) { + this.focusedOptionIndex++; + + // Previous action + } else if (['ArrowUp', 'ArrowLeft'].includes(event.key)) { + this.focusedOptionIndex--; + // Select action + } else if (event.key === 'Escape') { + this.focusedOptionIndex = -1; + this.close() + return; + } else { + // Other keys are not our interest here + return; + } + + // Don't exceed array indexes + this.focusedOptionIndex = Math.max( + 0, + Math.min(this.focusedOptionIndex, this.options.length - 1) + ); + + this.options[this.focusedOptionIndex].focus(); + + event.preventDefault(); + } + + get options(): BlDropdownItem[] { + return [].slice.call(this.querySelectorAll(blDropdownItemTag)); + } + + open() { + this._isPopoverOpen = true; + this._setupPopover(); + this.onOpen('Dropdown opened!'); + document.addEventListener('click', this._handleClickOutside); + } + + close() { + this._isPopoverOpen = false; + this.onClose('Dropdown closed!'); + this._cleanUpPopover && this._cleanUpPopover(); + document.removeEventListener('click', this._handleClickOutside); + } + + render(): TemplateResult { + const popoverClasses = classMap({ + popover: true, + visible: this.opened, + }); + + return html` + ${this.label} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blDropdownTag]: BlDropdown; + } +} diff --git a/src/components/dropdown/group/bl-dropdown-group.css b/src/components/dropdown/group/bl-dropdown-group.css new file mode 100644 index 00000000..4653c002 --- /dev/null +++ b/src/components/dropdown/group/bl-dropdown-group.css @@ -0,0 +1,38 @@ +:host { + position: relative; + width: 100%; +} + +.dropdown-group { + display: flex; + flex-direction: column; + gap: var(--bl-size-xs); +} + +.caption { + font: var(--bl-font-caption); + font-size: var(--bl-font-size-xs); + font-weight: var(--bl-font-weight-medium); + line-height: var(--bl-font-size-s); + color: var(--bl-color-content-secondary); +} + +:host(:not(:first-child)) { + border-top: 1px solid var(--bl-color-border); +} + +:host(:not(:last-child)) { + border-bottom: 1px solid var(--bl-color-border); +} + +:host(:not([caption])) ::slotted(:first-child) { + padding-block: var(--bl-size-xs) 0; +} + +:host(:not(:last-child)) ::slotted(:last-child) { + padding-block: 0 var(--bl-size-xs); +} + +:host(:not(:first-child)) .caption { + padding-block: var(--bl-size-xs) 0; +} diff --git a/src/components/dropdown/group/bl-dropdown-group.stories.mdx b/src/components/dropdown/group/bl-dropdown-group.stories.mdx new file mode 100644 index 00000000..7417a538 --- /dev/null +++ b/src/components/dropdown/group/bl-dropdown-group.stories.mdx @@ -0,0 +1,50 @@ +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { + Meta, + Canvas, + ArgsTable, + Story, +} from '@storybook/addon-docs'; + + + +# Dropdown Group +Dropdown Group component is a component should be used inside a bl-dropdown or bl-dropdown-group component. It is used to display an action in the these components. + +export const Template = (args) => html` + + Action outside of Group + Action 1Action 1 + +` + +## Basic Usage + + + {Template.bind({})} + + + +## With Caption + + + {Template.bind({})} + + + + + + diff --git a/src/components/dropdown/group/bl-dropdown-group.test.ts b/src/components/dropdown/group/bl-dropdown-group.test.ts new file mode 100644 index 00000000..ddbac138 --- /dev/null +++ b/src/components/dropdown/group/bl-dropdown-group.test.ts @@ -0,0 +1,34 @@ +import BlDropdownGroup from './bl-dropdown-group'; +import { + assert, + fixture, + html, + } from '@open-wc/testing'; + +import type typeOfBlDropdownGroup from './bl-dropdown-group'; + +describe('bl-dropdown-group', () => { + it('is defined', () => { + const el = document.createElement('bl-dropdown-group'); + assert.instanceOf(el, BlDropdownGroup); + }); + + it('should render with the default values', async () => { + const el = await fixture(html``); + assert.shadowDom.equal( + el, + ` + + ` + ); + }); + it('should render with caption', async () => { + const el = await fixture(html``); + assert.shadowDom.equal( + el, + ` + + ` + ); + }); +}); diff --git a/src/components/dropdown/group/bl-dropdown-group.ts b/src/components/dropdown/group/bl-dropdown-group.ts new file mode 100644 index 00000000..21c0e771 --- /dev/null +++ b/src/components/dropdown/group/bl-dropdown-group.ts @@ -0,0 +1,42 @@ +import { LitElement, html, CSSResultGroup, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import style from './bl-dropdown-group.css'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +export const blDropdownGroupTag = 'bl-dropdown-group'; + +/** + * @tag bl-dropdown-group + * @summary Baklava Dropdown Group component + */ +@customElement(blDropdownGroupTag) +export default class BlDropdownGroup extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Sets the caption. + */ + @property({ type: String }) + caption?: string; + + + render(): TemplateResult { + const caption = this.caption ? html`${this.caption}` : '' + + return html`` + } + +} + + +declare global { + interface HTMLElementTagNameMap { + [blDropdownGroupTag]: BlDropdownGroup; + } +} + diff --git a/src/components/dropdown/item/bl-dropdown-item.css b/src/components/dropdown/item/bl-dropdown-item.css new file mode 100644 index 00000000..66542d31 --- /dev/null +++ b/src/components/dropdown/item/bl-dropdown-item.css @@ -0,0 +1,6 @@ +:host { + width: 100%; + + --bl-button-display: block; + --bl-button-justify: start; +} diff --git a/src/components/dropdown/item/bl-dropdown-item.stories.mdx b/src/components/dropdown/item/bl-dropdown-item.stories.mdx new file mode 100644 index 00000000..caa890e3 --- /dev/null +++ b/src/components/dropdown/item/bl-dropdown-item.stories.mdx @@ -0,0 +1,47 @@ +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { + Meta, + Canvas, + ArgsTable, + Story, +} from '@storybook/addon-docs'; + + + +# Dropdown Item +Dropdown Item component is a component should be used inside a bl-dropdown or bl-dropdown-group component. It is used to display an action in the these components. + +export const Template = (args) => html` + Dropdown Item +` + +## Basic Usage + + + {Template.bind({})} + + + +## With Icon +If you want to have an icon for your actions, you can use the `icon` attribute. + + + {Template.bind({})} + + + + + diff --git a/src/components/dropdown/item/bl-dropdown-item.test.ts b/src/components/dropdown/item/bl-dropdown-item.test.ts new file mode 100644 index 00000000..efc1b9b1 --- /dev/null +++ b/src/components/dropdown/item/bl-dropdown-item.test.ts @@ -0,0 +1,64 @@ +import BlDropdownItem from './bl-dropdown-item'; +import { + assert, + fixture, + html, + oneEvent, + expect, + } from '@open-wc/testing'; + +import type typeOfBlDropdownItem from './bl-dropdown-item'; + +describe('bl-dropdown-item', () => { + it('is defined', () => { + const el = document.createElement('bl-dropdown-item'); + assert.instanceOf(el, BlDropdownItem); + }); + + it('should render with the default values', async () => { + const el = await fixture(html``); + assert.shadowDom.equal( + el, + ` + + + + + ` + ); + }); + it('should render with icon', async () => { + const el = await fixture(html``); + assert.shadowDom.equal( + el, + ` + + + + + ` + ); + }); + it('should fire event when click dropdown-item', async () => { + const el = await fixture(html`dropdown-item`); + const button = el.shadowRoot?.querySelector('bl-button'); + + setTimeout(() => button?.click()); + const event = await oneEvent(el, 'bl-dropdown-item-click'); + + expect(el).to.exist; + expect(event).to.exist; + expect(event.detail).to.be.equal('Action clicked!'); + }); +}); diff --git a/src/components/dropdown/item/bl-dropdown-item.ts b/src/components/dropdown/item/bl-dropdown-item.ts new file mode 100644 index 00000000..7dd532d7 --- /dev/null +++ b/src/components/dropdown/item/bl-dropdown-item.ts @@ -0,0 +1,84 @@ +import { LitElement, html, CSSResultGroup, TemplateResult } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { event, EventDispatcher } from '../../../utilities/event'; +import type BlDropdownGroup from '../group/bl-dropdown-group'; +import type BlDropdown from '../bl-dropdown'; + +import { blDropdownGroupTag } from '../group/bl-dropdown-group'; +import { blDropdownTag } from '../bl-dropdown'; + +import style from './bl-dropdown-item.css'; + +import '../../button/bl-button'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import BlButton from '../../button/bl-button'; + +export const blDropdownItemTag = 'bl-dropdown-item'; + +/** + * @tag bl-dropdown-item + * @summary Baklava Dropdown Item component + */ +@customElement(blDropdownItemTag) +export default class BlDropdownItem extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Sets the icon name. Shows icon with bl-icon component + */ + + @property({ type: String }) + icon?: string; + + @event('bl-dropdown-item-click') private onClick: EventDispatcher; + + private _handleClick() { + this.onClick('Action clicked!'); + } + + @query('[role=menuitem]') private menuElement: BlButton; + + /** + * Focuses this action + */ + focus() { + this.menuElement.focus(); + } + + private BlDropdownGroupField: BlDropdownGroup | null; + private BlDropdownField: BlDropdown | null; + + connectedCallback(): void { + super.connectedCallback(); + + this.BlDropdownGroupField = this.closest(blDropdownGroupTag); + this.BlDropdownField = this.closest(blDropdownTag); + + if (!this.BlDropdownField && !this.BlDropdownGroupField) { + console.warn(`bl-dropdown-item is designed to be used inside a ${blDropdownGroupTag} or ${blDropdownTag}`, this); + } + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + render(): TemplateResult { + return html` + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blDropdownItemTag]: BlDropdownItem; + } +} diff --git a/src/components/select/bl-select.test.ts b/src/components/select/bl-select.test.ts index 59b52bbd..b2e25915 100644 --- a/src/components/select/bl-select.test.ts +++ b/src/components/select/bl-select.test.ts @@ -90,7 +90,7 @@ describe('bl-select', () => { const selectInput = el.shadowRoot?.querySelector('.select-input'); selectInput?.click(); - expect(el.isPopoverOpen).to.true; + expect(el.opened).to.true; }); it('should close select menu', async () => { const el = await fixture(html`button`); @@ -99,7 +99,7 @@ describe('bl-select', () => { selectInput?.click(); selectInput?.click(); - expect(el.isPopoverOpen).to.false; + expect(el.opened).to.false; }); it('should close select menu when click outside & run validations', async () => { const el = await fixture(html` @@ -115,7 +115,7 @@ describe('bl-select', () => { setTimeout(() => { const invalidText = el.shadowRoot?.querySelector('.invalid-text'); - expect(el.isPopoverOpen).to.false; + expect(el.opened).to.false; expect(el.isInvalid).to.true; expect(invalidText).to.exist; }); diff --git a/src/components/select/bl-select.ts b/src/components/select/bl-select.ts index 1718ee0c..d3ca33d6 100644 --- a/src/components/select/bl-select.ts +++ b/src/components/select/bl-select.ts @@ -114,7 +114,7 @@ export default class BlSelect extends LitElement { return this._connectedOptions; } - get isPopoverOpen() { + get opened() { return this._isPopoverOpen; } @@ -239,7 +239,7 @@ export default class BlSelect extends LitElement { return html`
0, 'invalid': this._isInvalid, })}