From e65cb90a68973769a9b800901cdb8d5d67759640 Mon Sep 17 00:00:00 2001 From: Mustafa Date: Mon, 26 Sep 2022 13:52:07 +0300 Subject: [PATCH] feat: select component (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: select components initialized * feat: single & multi select * feat: click-outisde handler * feat: select component * feat: floating-ui * feat: tests added * feat: stories, invalid text * feat: disabled state * fix: review notes * fix: pr reviews * fix: tests & max-height * fix: disable state & stories * fix: decorators added to stories * fix: positioning, pr reviews * fix: ui notes Co-authored-by: Mustafa Adıgüzel Co-authored-by: Murat Çorlu <127687+muratcorlu@users.noreply.github.com> --- scripts/generate-react-exports.js | 2 +- src/baklava.ts | 2 + src/components/select/bl-select.css | 239 +++++++++++ src/components/select/bl-select.stories.mdx | 189 +++++++++ src/components/select/bl-select.test.ts | 208 ++++++++++ src/components/select/bl-select.ts | 388 ++++++++++++++++++ .../select/option/bl-select-option.css | 63 +++ .../option/bl-select-option.stories.mdx | 58 +++ .../select/option/bl-select-option.test.ts | 9 + .../select/option/bl-select-option.ts | 105 +++++ 10 files changed, 1262 insertions(+), 1 deletion(-) create mode 100644 src/components/select/bl-select.css create mode 100644 src/components/select/bl-select.stories.mdx create mode 100644 src/components/select/bl-select.test.ts create mode 100644 src/components/select/bl-select.ts create mode 100644 src/components/select/option/bl-select-option.css create mode 100644 src/components/select/option/bl-select-option.stories.mdx create mode 100644 src/components/select/option/bl-select-option.test.ts create mode 100644 src/components/select/option/bl-select-option.ts diff --git a/scripts/generate-react-exports.js b/scripts/generate-react-exports.js index a98204a1..3bf30aa7 100644 --- a/scripts/generate-react-exports.js +++ b/scripts/generate-react-exports.js @@ -7,7 +7,7 @@ function writeBaklavaReactFile(fileContentParts) { // @ts-nocheck import React from 'react'; import { createComponent } from '@lit-labs/react'; - + ${fileContentParts.join('\n\n')} `; diff --git a/src/baklava.ts b/src/baklava.ts index 2a857d82..4ec240b2 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -9,4 +9,6 @@ export { default as BlTooltip } from './components/tooltip/bl-tooltip'; export { default as BlProgressIndicator } from './components/progress-indicator/bl-progress-indicator'; export { default as BlCheckbox } from './components/checkbox/bl-checkbox'; export { default as BlAlert } from './components/alert/bl-alert'; +export { default as BlSelect } from './components/select/bl-select'; +export { default as BlSelectOption } from './components/select/option/bl-select-option'; export { getIconPath, setIconPath } from './utilities/asset-paths'; diff --git a/src/components/select/bl-select.css b/src/components/select/bl-select.css new file mode 100644 index 00000000..1b161afc --- /dev/null +++ b/src/components/select/bl-select.css @@ -0,0 +1,239 @@ +:host { + width: 200px; + display: inline-block; +} + +.select-wrapper { + width: 100%; + position: relative; + + --padding-vertical: var(--bl-size-2xs); + --padding-horizontal: var(--bl-size-xs); + --background-color: #fff; + --border-color: var(--bl-color-border); + --border-focus-color: var(--bl-color-primary-hover); + --icon-color: var(--bl-color-content-tertiary); + --text-color: var(--bl-color-content-primary); + --label-color: var(--bl-color-content-secondary); + --placeholder-color: var(--bl-color-content-tertiary); + --height: var(--bl-size-2xl); + --menu-padding: 0 var(--bl-size-m); + --menu-margin-top: var(--bl-size-2xs); + --font-size: var(--bl-font-size-m); + --disabled-color: var(--bl-color-tertiary); + --z-index: 1; + --menu-height: 250px; +} + +:host([size='large']) .select-wrapper { + --height: var(--bl-size-3xl); + --padding-vertical: var(--bl-size-xs); + --padding-horizontal: var(--bl-size-m); +} + +:host([size='small']) .select-wrapper { + --height: var(--bl-size-xl); + --padding-vertical: var(--bl-size-3xs); + --padding-horizontal: var(--bl-size-xs); + --font-size: var(--bl-font-size-s); +} + +.placeholder { + color: var(--placeholder-color); +} + +:host([disabled]) .placeholder { + --placeholder-color: var(--bl-color-content-passive); +} + +.invalid { + --border-color: var(--bl-color-danger); + --border-focus-color: var(--bl-color-danger-hover); + --label-color: var(--bl-color-danger); +} + +.select-input { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + outline: none; + box-sizing: border-box; + height: var(--height); + border: solid 1px var(--border-color); + font: var(--bl-font-title-3-regular); + padding: 0 var(--padding-horizontal); + border-radius: var(--bl-border-radius-s); + color: var(--text-color); + user-select: none; +} + +.remove-all { + display: none; +} + +.remove-all::after { + content: ''; + position: absolute; + left: 1.5rem; + bottom: 4px; + height: 1rem; + border-left: 1px solid var(--bl-color-border); +} + +.selected .remove-all { + display: block; +} + +:host([disabled]) .remove-all, +:host([disabled]) .remove-all::after { + display: none; +} + +.dropdown-icon { + font-size: var(--bl-font-size-m); +} + +.selected .dropdown-icon { + --icon-color: var(--bl-color-secondary); +} + +:host([disabled]) .dropdown-icon { + --icon-color: var(--bl-color-content-passive); +} + +.select-open .select-input { + border: solid 1px var(--border-focus-color); +} + +:host([disabled]) { + cursor: not-allowed; +} + +:host([disabled]) .select-input { + pointer-events: none; + background-color: var(--disabled-color); +} + +.select-input .selected-options { + padding: 0; + margin: 0; + list-style: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.selected-options li { + display: inline; + font-size: var(--font-size); + color: var(--text-color); +} + +.selected-options li:not(:last-child)::after { + content: ', '; +} + +:host([disabled]) .selected-options li +{ + color: var(--bl-color-content-passive); +} + +.select-input .actions { + display: flex; + align-items: center; + justify-content: center; + gap: var(--bl-size-2xs); + margin-left: var(--bl-size-2xs); +} + +.popover { + --left: 0; + --top: 0; + + position: fixed; + border: solid 1px var(--border-color); + background-color: var(--background-color); + font: var(--bl-font-title-3-regular); + border-radius: var(--bl-border-radius-s); + padding: var(--menu-padding); + outline: none; + box-sizing: border-box; + max-height: var(--menu-height); + overflow-y: auto; + display: none; + flex-direction: column; + z-index: var(--z-index); + width: 100%; + top: var(--top); + left: var(--left); +} + +.select-open .popover { + display: flex; + border: solid 1px var(--border-focus-color); +} + +bl-icon { + color: var(--icon-color); +} + +label { + position: absolute; + display: flex; + align-items: center; + top: var(--padding-vertical); + left: var(--padding-horizontal); + transition: all ease-in 0.2s; + pointer-events: none; + font: var(--bl-font-title-3-regular); + font-size: var(--font-size); + color: var(--placeholder-color); + padding: 0; +} + +:where(.select-open, .selected) label { + top: 0; + left: var(--bl-size-2xs); + transform: translateY(-50%); + font: var(--bl-font-form-label); + color: var(--label-color); + padding: 0 var(--bl-size-3xs); + background-color: var(--bl-color-primary-background); + pointer-events: initial; +} + +:host([label-fixed]) .select-wrapper { + padding-top: var(--bl-size-m); +} + +:host([label-fixed]) label { + top: 0; + left: 0; + transition: none; + transform: none; + pointer-events: initial; + font: var(--bl-font-form-label); + color: var(--label-color); + padding: 0; +} + +.help-text, +.invalid-text { + margin: var(--bl-size-3xs) 0 0 var(--bl-size-2xs); + font: var(--bl-font-title-4-regular); + padding: var(--bl-size-3xs) var(--bl-input-padding-horizontal); +} + +.help-text { + color: var(--bl-color-content-secondary); +} + +.invalid-text { + color: var(--bl-color-danger); +} + +.select-open .help-text, +.select-open .invalid-text { + visibility: hidden; +} diff --git a/src/components/select/bl-select.stories.mdx b/src/components/select/bl-select.stories.mdx new file mode 100644 index 00000000..395210cb --- /dev/null +++ b/src/components/select/bl-select.stories.mdx @@ -0,0 +1,189 @@ +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { + Meta, + Canvas, + ArgsTable, + Story, +} from '@storybook/addon-docs'; +import { userEvent } from '@storybook/testing-library'; + + + +export const selectOpener = async ({ canvasElement }) => { + const select = canvasElement?.querySelector('bl-select') + if(select.shadowRoot) { + const selectInput = select.shadowRoot.querySelector('.select-input') + await userEvent.click(selectInput); + } +} + +export const SelectTemplate = (args) => html` + Turkey + Netherlands +` + +export const SelectOptionsSelectedTemplate = (args) => html` + Turkey + Netherlands +` + +# Select +Select component is a component for selecting a value from a list of options. +Each option should be wrapped with `bl-select-option` component. + +## Basic Usage + + + {SelectTemplate.bind({})} + + + {SelectOptionsSelectedTemplate.bind({})} + + + +## Multiple Select +There will be checkboxes in select menu when `multiple` attribute is set to true. +Selected options will be visible on input seperated by commas. + + + {SelectTemplate.bind({})} + + + {SelectOptionsSelectedTemplate.bind({})} + + + +## Select Labels + +Select component optionally can have a `label`. +If the label is set, it will be a floating label by default. +If you want to use always it on top of the input, then you can use `label-fixed` attribute. + + + + {SelectTemplate.bind({})} + + + {SelectTemplate.bind({})} + + + {SelectTemplate.bind({})} + + + +## Disabled State + +Select component can be disabled by using `disabled` attribute. + + + + {SelectTemplate.bind({})} + + + {SelectOptionsSelectedTemplate.bind({})} + + + +## Select Help Text + +You can give extra information to user with `help-text` attribute. + + + + {SelectTemplate.bind({})} + + + +## Select Validation + +Select component supports only `required` rule just for now. Other validation rules will come soon. + +Select validation will run after user selects a value and go out from the input. If there is a validation issue, select will be highlighted in error state. After this state every change will have immediate effect on input to update validation state. + + + + {SelectTemplate.bind({})} + + + +You can set an error message by setting `invalid-text` attribute. + + + + {SelectTemplate.bind({})} + + + +## Select Sizes + +Select have 3 size options: `small`, `medium` and `large`. `medium` size is default and if you want to show select in another size you can set `size` attribute to `large` or `small`. + + + + {SelectTemplate.bind({})} + + + {SelectTemplate.bind({})} + + + {SelectTemplate.bind({})} + + + +## Reference + + diff --git a/src/components/select/bl-select.test.ts b/src/components/select/bl-select.test.ts new file mode 100644 index 00000000..e5c428a3 --- /dev/null +++ b/src/components/select/bl-select.test.ts @@ -0,0 +1,208 @@ +import BlSelect from './bl-select'; +import { assert, expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { BlIcon, BlSelectOption } from '../../baklava'; +import BlCheckbox from '../checkbox/bl-checkbox'; + +describe('bl-select', () => { + it('is defined', () => { + const el = document.createElement('bl-select'); + assert.instanceOf(el, BlSelect); + }); + + it('renders with default values', async () => { + const el = await fixture(html``); + assert.shadowDom.equal( + el, + ` +
+
+ +
    +
    + + + +
    +
    +
    + +
    +
    + ` + ); + }); + it('should set label', async () => { + const labelText = 'Some Label'; + const el = await fixture(html``); + const label = el.shadowRoot?.querySelector('label'); + + expect(label).to.exist; + expect(label?.innerText).to.equal(labelText); + }); + it('should set help text', async () => { + const helpText = 'Some help text'; + const el = await fixture(html``); + const helpMessage = el.shadowRoot?.querySelector('.help-text'); + + expect(helpMessage).to.exist; + expect(helpMessage?.innerText).to.equal(helpText); + }); + it('should render bl-select-options', async () => { + const el = await fixture(html` + Option 1 + Option 2 + `); + + expect(el.options.length).to.equal(2); + }); + it('should render bl-select-options when multiple options is true', async () => { + const el = await fixture(html` + Option 1 + Option 2 + `); + + expect(el.options.length).to.equal(2); + }); + it('should render bl-select-options when there is a selected option', async () => { + const el = await fixture(html` + Option 1 + Option 2 + `); + + expect(el.options.length).to.equal(2); + expect(el.selectedOptions.length).to.equal(1); + }); + it('should render bl-select-options when multiple options is true and there are selected options', async () => { + const el = await fixture(html` + Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + `); + + expect(el.options.length).to.equal(5); + expect(el.selectedOptions.length).to.equal(4); + expect(el.additionalSelectedOptionCount).to.equal(1); + }); + it('should open select menu', async () => { + const el = await fixture(html`button`); + + const selectInput = el.shadowRoot?.querySelector('.select-input'); + selectInput?.click(); + + expect(el.isPopoverOpen).to.true; + }); + it('should close select menu', async () => { + const el = await fixture(html`button`); + + const selectInput = el.shadowRoot?.querySelector('.select-input'); + selectInput?.click(); + selectInput?.click(); + + expect(el.isPopoverOpen).to.false; + }); + it('should close select menu when click outside & run validations', async () => { + const el = await fixture(html` + + `); + + const selectInput = el.shadowRoot?.querySelector('.select-input'); + selectInput?.click(); + + const body = el.closest('body'); + body.click(); + + setTimeout(() => { + const invalidText = el.shadowRoot?.querySelector('.invalid-text'); + + expect(el.isPopoverOpen).to.false; + expect(el.isInvalid).to.true; + expect(invalidText).to.exist; + }); + }); + it('should remove selected options', async () => { + const el = await fixture(html` + Option 1 + Option 2 + `); + + const removeAll = el.shadowRoot?.querySelector('.remove-all'); + setTimeout(() => removeAll?.click()); + + const event = await oneEvent(el, 'bl-select'); + + expect(event).to.exist; + expect(event.detail).to.eql([]); + expect(el.options.length).to.equal(2); + expect(el.selectedOptions.length).to.equal(0); + }); + it('should fire event when click select option when it is not selected', async () => { + const el = await fixture(html` + Option 1 + Option 2 + `); + + const selectOption = el.querySelector('bl-select-option[value="1"]'); + + const selectOptionCheckbox = selectOption.shadowRoot?.querySelector('bl-checkbox'); + const checkboxEvent = new CustomEvent('bl-checkbox-change', { + detail: true, + }); + selectOptionCheckbox?.dispatchEvent(checkboxEvent); + + expect(el.selectedOptions.length).to.equal(2); + }); + it('should fire event when click select option', async () => { + const el = await fixture(html` + Option 1 + Option 2 + `); + + const selectOption = el.querySelector('bl-select-option[value="1"]'); + const selectOptionDiv = ( + selectOption.shadowRoot?.querySelector('.single-option') + ); + + setTimeout(() => selectOptionDiv?.click()); + const event = await oneEvent(el, 'bl-select'); + + expect(event).to.exist; + expect(event.detail.length).to.equal(1); + expect(el.selectedOptions.length).to.equal(1); + }); + it('should remove selected item if it is already selected', async () => { + const el = await fixture(html` + Option 1 + Option 2 + `); + + const selectOption = el.querySelector('bl-select-option[value="2"]'); + const selectOptionCheckbox = selectOption.shadowRoot?.querySelector('bl-checkbox'); + const checkboxEvent = new CustomEvent('bl-checkbox-change', { + detail: false, + }); + selectOptionCheckbox?.dispatchEvent(checkboxEvent); + + expect(el.selectedOptions.length).to.equal(0); + }); + it('should clear connected options & selected items when multiple property has changed', async () => { + const el = await fixture(html` + Option 1 + Option 2 + `); + + el.removeAttribute('multiple'); + setTimeout(() => { + const selectOption = el.querySelector('bl-select-option[selected]'); + + expect(selectOption).is.not.exist; + }); + }); +}); diff --git a/src/components/select/bl-select.ts b/src/components/select/bl-select.ts new file mode 100644 index 00000000..ec0d4d63 --- /dev/null +++ b/src/components/select/bl-select.ts @@ -0,0 +1,388 @@ +import { LitElement, html, CSSResultGroup, PropertyValues } from 'lit'; +import { customElement, property, state, query, queryAll } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { computePosition, flip, MiddlewareArguments, offset, size, autoUpdate } from '@floating-ui/dom'; +import style from '../select/bl-select.css'; +import '../icon/bl-icon'; +import '../select/option/bl-select-option'; +import type BlSelectOption from './option/bl-select-option'; +import { event, EventDispatcher } from '../../utilities/event'; + +export interface ISelectOption { + value: string; + text: string; + selected: boolean; +} + +export type SelectSize = 'medium' | 'large' | 'small'; + +export type CleanUpFunction = () => void; + +@customElement('bl-select') +export default class BlSelect extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /* Declare reactive properties */ + /** + * Sets the label value + */ + @property({}) + label?: string; + + /** + * Sets the placeholder value. If left blank, the label value (if specified) is set as placeholder. + */ + @property({}) + placeholder?: string; + + /** + * Sets the size value. Select component's height value will be changed accordingly + */ + @property({ type: String, reflect: true }) + size: SelectSize = 'medium'; + + /** + * When option is not selected, shows component in error state + */ + @property({ type: Boolean }) + required = false; + + /** + * Shows the component in disabled state. + */ + @property({ type: Boolean }) + disabled = false; + + /** + * Allows multiple options to be selected + */ + @property({ type: Boolean }) + multiple = false; + + /** + * Makes label as fixed positioned + */ + @property({ type: Boolean, attribute: 'label-fixed' }) + labelFixed = false; + + /** + * Adds help text + */ + @property({ type: String, attribute: 'help-text' }) + helpText?: string; + + /** + * Set custom error message + */ + @property({ type: String, attribute: 'invalid-text' }) + customInvalidText?: string; + + /* Declare internal reactive properties */ + @state() + private _isPopoverOpen = false; + + @state() + private _selectedOptions: ISelectOption[] = []; + + @state() + private _additionalSelectedOptionCount = 0; + + @state() + private _isInvalid = false; + + @query('.selected-options') + private _selectedOptionsContainer!: HTMLElement; + + @queryAll('.selected-options li') + private _selectedOptionsItems!: Array; + + @query('.popover') + private _popover: HTMLElement; + + @query('.select-input') + private _selectInput: HTMLElement; + + @event('bl-select') private _onBlSelect: EventDispatcher; + + private _connectedOptions: BlSelectOption[] = []; + + private _cleanUpPopover: CleanUpFunction | null = null; + + get options() { + return this._connectedOptions; + } + + get isPopoverOpen() { + return this._isPopoverOpen; + } + + get selectedOptions() { + return this._selectedOptions; + } + + get additionalSelectedOptionCount() { + return this._additionalSelectedOptionCount; + } + + get isInvalid() { + return this._isInvalid; + } + + open() { + this._isPopoverOpen = true; + this._setupPopover(); + } + + close() { + this._isPopoverOpen = false; + this._cleanUpPopover && this._cleanUpPopover(); + } + + private _clickOutsideHandler = (event: MouseEvent) => { + const target = event.target as HTMLElement; + + if (!this.contains(target) && this._isPopoverOpen) { + this.close(); + this._checkRequired(); + } + }; + + private _setupPopover() { + this._cleanUpPopover = autoUpdate(this._selectInput, this._popover, () => { + computePosition(this._selectInput, this._popover, { + placement: 'bottom', + strategy: 'fixed', + middleware: [ + flip(), + offset(8), + size({ + apply(args: MiddlewareArguments) { + Object.assign(args.elements.floating.style, { + width: `${args.elements.reference.getBoundingClientRect().width}px`, + }); + }, + }), + ], + }).then(({ x, y }) => { + this._popover.style.setProperty('--left', `${x}px`); + this._popover.style.setProperty('--top', `${y}px`); + }); + }); + } + + connectedCallback() { + super.connectedCallback(); + + document.addEventListener('click', this._clickOutsideHandler); + } + disconnectedCallback() { + super.disconnectedCallback(); + + document.removeEventListener('click', this._clickOutsideHandler); + this._cleanUpPopover && this._cleanUpPopover(); + } + + private inputTemplate() { + const inputSelectedOptions = html`
      + ${this._selectedOptions.map(item => html`
    • ${item.text}
    • `)} +
    `; + const _selectedItemCount = this._additionalSelectedOptionCount + ? html`+${this._additionalSelectedOptionCount}` + : null; + const removeButton = html``; + const placeholder = this._showPlaceHolder + ? html`${this.placeholder}` + : ''; + + return html`
    + ${placeholder} ${inputSelectedOptions} ${_selectedItemCount} +
    + ${removeButton} + +
    +
    `; + } + + private menuTemplate() { + return html`
    + +
    `; + } + + render() { + const invalidMessage = this._isInvalid && this.customInvalidText + ? html`

    ${this.customInvalidText}

    ` : ``; + const helpMessage = this.helpText && !invalidMessage + ? html`

    ${this.helpText}

    ` : ``; + const label = this.label + ? html`` : ''; + + return html`
    0, + 'invalid': this._isInvalid, + })} + tabindex="-1" + > + ${label} ${this.inputTemplate()} ${this.menuTemplate()} ${helpMessage} ${invalidMessage} +
    `; + } + + private get _showPlaceHolder() { + if (this.label && !this.labelFixed) { + return !this._selectedOptions.length && this._isPopoverOpen; + } + return !this._selectedOptions.length; + } + + private _onClickSelectInput() { + this._isPopoverOpen ? this.close() : this.open(); + } + + private _handleSelectEvent() { + this._onBlSelect(this._selectedOptions); + } + + private _handleSingleSelect(optionItem: ISelectOption) { + const oldItem = this._connectedOptions.find(option => option.selected); + + if (oldItem) { + oldItem.selected = false; + } + + this._selectedOptions = [optionItem]; + this._handleSelectEvent(); + this._isPopoverOpen = false; + } + + private _handleMultipleSelect(optionItem: ISelectOption) { + const { value } = optionItem; + + if (!optionItem.selected) { + this._selectedOptions = this._selectedOptions.filter(item => item.value !== value); + } else { + this._selectedOptions = [...this._selectedOptions, optionItem]; + } + + this._handleSelectEvent(); + } + + private _handleSelectOptionEvent(e: CustomEvent) { + const optionItem = e.detail as ISelectOption; + + if (this.multiple) { + this._handleMultipleSelect(optionItem); + } else { + this._handleSingleSelect(optionItem); + } + } + + private _onClickRemove(e: MouseEvent) { + e.stopPropagation(); + + this._connectedOptions + .filter(option => option.selected) + .forEach(option => { + option.selected = false; + }); + + this._selectedOptions = []; + this._handleSelectEvent(); + } + + private _checkAdditionalItemCount() { + if (!this.multiple) return; + + let visibleItems = 0; + for(const value of this._selectedOptionsItems) { + if (value.offsetLeft < this._selectedOptionsContainer.offsetWidth) { + visibleItems++; + } else { + break; + } + } + + this._additionalSelectedOptionCount = this._selectedOptionsItems.length - visibleItems; + } + + private _checkRequired() { + if (this.required) { + this._isInvalid = this._selectedOptions.length === 0; + } + } + + protected updated(_changedProperties: PropertyValues) { + if ( + _changedProperties.has('_selectedOptions') && + _changedProperties.get('_selectedOptions') instanceof Array + ) { + this._checkRequired(); + this._checkAdditionalItemCount(); + } else if ( + _changedProperties.has('multiple') && + typeof _changedProperties.get('multiple') === 'boolean' + ) { + this._connectedOptions.forEach(option => { + option.multiple = this.multiple; + option.selected = false; + }); + this._selectedOptions = []; + } + } + + /** + * This method is used by `bl-select-option` component to register itself to bl-select. + * @param option BlSelectOption reference to be registered + */ + registerOption(option: BlSelectOption) { + this._connectedOptions.push(option); + + if (option.selected) { + const optionItem = { + value: option.value, + text: option.textContent, + selected: option.selected, + } as ISelectOption; + + if (this.multiple) { + this._selectedOptions = [...this._selectedOptions, optionItem]; + } else { + this._selectedOptions = [optionItem]; + } + + this.requestUpdate(); + } + } + + /** + * This method is used by `bl-select-option` component to unregister itself from bl-select. + * @param option BlSelectOption reference to be unregistered + */ + unregisterOption(option: BlSelectOption) { + this._connectedOptions.splice(this._connectedOptions.indexOf(option), 1); + this._selectedOptions = this._selectedOptions.filter(item => item.value !== option.value); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'bl-select': BlSelect; + } +} diff --git a/src/components/select/option/bl-select-option.css b/src/components/select/option/bl-select-option.css new file mode 100644 index 00000000..f7cf0114 --- /dev/null +++ b/src/components/select/option/bl-select-option.css @@ -0,0 +1,63 @@ +:host { + position: relative; +} + +.option-container { + --option-font: var(--bl-font-title-3-regular); + --option-padding: var(--bl-size-xs) 0; + --option-selected-color: var(--bl-color-primary); + --option-hover-color: var(--bl-color-primary-hover); + --option-color: var(--bl-color-secondary); + --option-disabled-color: var(--bl-color-content-passive); + --option-seperator: 1px solid var(--bl-color-border); + --option-gap: var(--bl-size-2xs); + --option-transition: color 120ms ease-out; +} + +.option-container::after { + position: absolute; + content: ''; + width: 100%; + bottom: 0; + border-bottom: var(--option-seperator); +} + +:host(:last-of-type) .option-container::after { + border-bottom: none; +} + +.single-option { + width: 100%; + display: block; + cursor: pointer; + color: var(--option-color); + padding: var(--option-padding); + transition: var(--option-transition); + font: var(--option-font); + user-select: none; +} + +:host(:hover) .single-option { + color: var(--option-hover-color); +} + +:host([selected]) .single-option { + color: var(--option-selected-color); +} + +:host([disabled]) { + cursor: not-allowed; +} + +:host([disabled]) .single-option { + color: var(--option-disabled-color); + cursor: not-allowed; + pointer-events: none; +} + +.checkbox-option { + width: 100%; + display: block; + color: var(--option-color); + padding: var(--option-padding); +} diff --git a/src/components/select/option/bl-select-option.stories.mdx b/src/components/select/option/bl-select-option.stories.mdx new file mode 100644 index 00000000..8b48019a --- /dev/null +++ b/src/components/select/option/bl-select-option.stories.mdx @@ -0,0 +1,58 @@ +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { + Meta, + Canvas, + ArgsTable, + Story, +} from '@storybook/addon-docs'; + + + +# Select Option +Select option component is a component should be used inside a select component. It is used to display a single option in the select component. + +export const Template = (args) => html` + baklava +` + +## Basic Usage + + + {Template.bind({})} + + + +## Selected +If you want to select an option by default, you can use the `selected` attribute. + + + {Template.bind({})} + + + +## Disabled +If you want to disable an option, you can use the `disabled` attribute. + + + {Template.bind({})} + + + + + diff --git a/src/components/select/option/bl-select-option.test.ts b/src/components/select/option/bl-select-option.test.ts new file mode 100644 index 00000000..755066ba --- /dev/null +++ b/src/components/select/option/bl-select-option.test.ts @@ -0,0 +1,9 @@ +import BlSelectOption from './bl-select-option'; +import { assert } from '@open-wc/testing'; + +describe('bl-select', () => { + it('is defined', () => { + const el = document.createElement('bl-select-option'); + assert.instanceOf(el, BlSelectOption); + }); +}); diff --git a/src/components/select/option/bl-select-option.ts b/src/components/select/option/bl-select-option.ts new file mode 100644 index 00000000..710e920f --- /dev/null +++ b/src/components/select/option/bl-select-option.ts @@ -0,0 +1,105 @@ +import { LitElement, html, CSSResultGroup } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { event, EventDispatcher } from '../../../utilities/event'; +import BlSelect, { ISelectOption } from '../bl-select'; +import style from './bl-select-option.css'; + +@customElement('bl-select-option') +export default class BlSelectOption extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /* Declare reactive properties */ + /** + * Sets the value for the option + */ + @property({}) + value: string; + + /** + * Sets option as disabled + */ + @property({ type: Boolean }) + disabled = false; + + /** + * Sets option as selected state + */ + @property({ type: Boolean, reflect: true }) + selected = false; + + @state() + multiple = false; + + /** + * Fires when clicked on the option + */ + @event('bl-select-option') private _onSelect: EventDispatcher; + + private blSelect: BlSelect | null; + + private singleOptionTemplate() { + return html`
    + +
    `; + } + + private checkboxOptionTemplate() { + return html` + + `; + } + + render() { + return html`
    + ${this.multiple ? this.checkboxOptionTemplate() : this.singleOptionTemplate()} +
    `; + } + + private _handleEvent() { + this._onSelect({ + value: this.value, + text: this.textContent, + selected: this.selected, + } as ISelectOption); + } + + private _onClickOption() { + this.selected = !this.selected; + this._handleEvent(); + } + + private _onCheckboxChange(event: CustomEvent) { + this.selected = event.detail; + this._handleEvent(); + } + + connectedCallback() { + super.connectedCallback(); + + this.updateComplete.then(() => { + this.blSelect = this.closest('bl-select'); + // FIXME: We should warn when parent is not bl-select + + this.multiple = this.blSelect?.multiple || false; + this.blSelect?.registerOption(this); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.blSelect?.unregisterOption(this); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'bl-select-option': BlSelectOption; + } +}