diff --git a/src/components/select/bl-select.css b/src/components/select/bl-select.css index 6a7621fa..1cd261dc 100644 --- a/src/components/select/bl-select.css +++ b/src/components/select/bl-select.css @@ -219,6 +219,15 @@ left: var(--left); } +.popover-no-result { + display: flex; + flex-direction: column; + gap: var(--bl-size-2xs); + align-items: center; + justify-content: center; + height: var(--menu-height); +} + .select-open .popover { display: flex; border: solid 1px var(--border-focus-color); @@ -361,3 +370,75 @@ legend span { bottom: 0; border-bottom: 1px solid var(--bl-color-neutral-lighter); } + +.search-bar-input { + font: var(--bl-font-title-3-regular); + font-size: var(--font-size); + color: var(--text-color); + border: none; + outline: none; + background-color: transparent; + width: 100%; + padding: 0; + margin: 0; + box-sizing: border-box; + height: var(--height); +} + +.search-bar-input::placeholder { + color: var(--placeholder-color); +} + +.search-bar-input:focus-visible { + outline: none; +} + +.search-mag-icon { + animation: wiggle 2s linear infinite; +} + +.search-loading-icon { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes wiggle { + 0%, + 7% { + transform: rotateZ(0); + } + + 15% { + transform: rotateZ(-15deg); + } + + 20% { + transform: rotateZ(10deg); + } + + 25% { + transform: rotateZ(-10deg); + } + + 30% { + transform: rotateZ(6deg); + } + + 35% { + transform: rotateZ(-4deg); + } + + 40%, + 100% { + transform: rotateZ(0); + } +} diff --git a/src/components/select/bl-select.stories.mdx b/src/components/select/bl-select.stories.mdx index efc1258b..6ec1b5fd 100644 --- a/src/components/select/bl-select.stories.mdx +++ b/src/components/select/bl-select.stories.mdx @@ -100,6 +100,9 @@ export const SelectTemplate = (args) => html` +## Searchable + +[ADR](https://github.com/Trendyol/baklava/issues/265#issuecomment-1845414216) +[Figma](https://www.figma.com/file/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?node-id=13059%3A6927) + +Select component can be searchable by using `search-bar` attribute. + + + + {SelectTemplate.bind({})} + + + + {SelectTemplate.bind({})} + + + + {SelectTemplate.bind({})} + + + ## `bl-select` Event Select component fires `bl-select` event once selection changes. This event has a payload in the type of diff --git a/src/components/select/bl-select.test.ts b/src/components/select/bl-select.test.ts index 9d6bd469..2cc09bff 100644 --- a/src/components/select/bl-select.test.ts +++ b/src/components/select/bl-select.test.ts @@ -280,6 +280,106 @@ describe("bl-select", () => { expect(selectOption).is.not.exist; }); + it("should show search input if search-bar attribute is given", async () => { + const el = await fixture(html` + Turkey + United States of America + `); + + const searchInput = el.shadowRoot?.querySelector("fieldset input"); + + expect(searchInput).to.exist; + }); + + it("should search 'Turkey' when 'turkey' is typed", async () => { + const el = await fixture(html` + Turkey + United States of America + `); + + const searchInput = el.shadowRoot?.querySelector("fieldset input"); + + searchInput?.focus(); + + await sendKeys({ + type: "turkey", + }); + + el.options.forEach(option => { + if (option.innerText === "Turkey") { + expect(option.hidden).to.be.false; + } else { + expect(option.hidden).to.be.true; + } + }); + }); + + it("should show loading icon when the search loading state is true", async () => { + const el = await fixture(html` + Turkey + United States of America + `); + + const searchInput = el.shadowRoot?.querySelector("fieldset input"); + + searchInput?.focus(); + + const loadingIcon = el.shadowRoot?.querySelector("fieldset bl-icon"); + + await sendKeys({ + type: "turkey", + }); + + expect(loadingIcon).to.exist; + }); + + it("should be displayed a 'no result' message if the searched term does not match with any option", async () => { + const el = await fixture(html` + Turkey + United States of America + `); + + const searchInput = el.shadowRoot?.querySelector("fieldset input"); + + searchInput?.focus(); + + await sendKeys({ + type: "netherlands", + }); + + const noResultContainer = el.shadowRoot?.querySelector(".popover .popover-no-result"); + const noResultMessage = el.shadowRoot?.querySelector(".popover .popover-no-result span")?.innerText; + + + el.options.forEach(option => { + expect(option.hidden).to.be.true; + }); + + expect(noResultContainer).to.exist; + expect(noResultMessage).to.equal("No Data Found"); + }); + + it("should be cleared the search input if the user click on the clear search button", async () => { + const el = await fixture(html` + Turkey + United States of America + `); + + const searchInput = el.shadowRoot?.querySelector("fieldset input"); + + searchInput?.focus(); + + await sendKeys({ + type: "netherlands", + }); + + const clearSearchButton = el.shadowRoot?.querySelector(".popover .popover-no-result bl-button"); + + clearSearchButton?.click(); + + setTimeout(() => expect(searchInput?.value).to.equal("")); + }); + describe("additional selection counter", () => { let el: BlSelect; @@ -649,5 +749,29 @@ describe("bl-select", () => { expect(selectAll.indeterminate).to.be.false; expect(selectAll.checked).to.be.false; }); - }); + }); + + describe("events", () => { + it("should fire search event when 'turkey' is typed", async () => { + const el = await fixture(html` + Turkey + United States of America + `); + + const searchInput = el.shadowRoot?.querySelector("fieldset input"); + + if (searchInput) { + searchInput.focus(); + + searchInput.value = "turkey"; + } + + setTimeout(() => searchInput?.dispatchEvent(new Event("input"))); + + const event = await oneEvent(el, "bl-search"); + + expect(event).to.exist; + expect(event.detail).to.equal("turkey"); + }); + }); }); diff --git a/src/components/select/bl-select.ts b/src/components/select/bl-select.ts index dbff8254..f945dd07 100644 --- a/src/components/select/bl-select.ts +++ b/src/components/select/bl-select.ts @@ -156,6 +156,36 @@ export default class BlSelect extends Form @property({ type: String, attribute: "select-all-text" }) selectAllText = "Select All"; + /** + * Enable search functionality for the options within the list + */ + @property({ type: Boolean, attribute: "search-bar", reflect: true }) + searchBar = false; + + /** + * Search for text variations such as "search," "searching," "search by country," and so on + */ + @property({ type: String, attribute: "search-bar-placeholder", reflect: true }) + searchBarPlaceholder?: string; + + /** + * Display a loading icon in place of the search icon. + */ + @property({ type: Boolean, attribute: "search-bar-loading-state", reflect: true }) + searchBarLoadingState = false; + + /** + * Text to display when no search results are found. + */ + @property({ type: String, attribute: "search-not-found-text", reflect: true }) + searchNotFoundText = "No Data Found"; + + /** + * Text to display on the clear search button. + */ + @property({ type: String, attribute: "popover-clear-search-text", reflect: true }) + popoverClearSearchText = "Clear Search"; + /* Declare internal reactive properties */ @state() private _isPopoverOpen = false; @@ -163,6 +193,9 @@ export default class BlSelect extends Form @state() private _additionalSelectedOptionCount = 0; + @state() + private _searchText = ""; + @query(".selected-options") private selectedOptionsContainer!: HTMLElement; @@ -180,6 +213,11 @@ export default class BlSelect extends Form */ @event("bl-select") private _onBlSelect: EventDispatcher[]>; + /** + * Fires when search text changes + */ + @event("bl-search") private _onBlSearch: EventDispatcher; + private _connectedOptions: BlSelectOption[] = []; private _cleanUpPopover: CleanUpFunction | null = null; @@ -203,6 +241,10 @@ export default class BlSelect extends Form return this._isPopoverOpen; } + get noResultFound() { + return this._searchText !== "" && this._connectedOptions.every(option => option.hidden); + } + @state() private _selectedOptions: BlSelectOption[] = []; @@ -252,6 +294,10 @@ export default class BlSelect extends Form } close() { + this._handleSearchOptions({ target: { value: "" } } as InputEvent & { + target: HTMLInputElement; + }); + this._isPopoverOpen = false; this.focusedOptionIndex = -1; this._cleanUpPopover && this._cleanUpPopover(); @@ -325,31 +371,82 @@ export default class BlSelect extends Form >` : ""; - return html`
`; + + const searchLoadingIcon = html``; + + const search = html`
0, })} tabindex="${this.disabled ? "-1" : 0}" - ?autofocus=${this.autofocus} - @click=${this.togglePopover} role="button" aria-haspopup="listbox" aria-expanded="${this.opened}" aria-labelledby="label" + @click=${this.open} > ${this.label} - ${this.placeholder} - ${this.label} - ${inputSelectedOptions} - +${this._additionalSelectedOptionCount} -
+ + ${this._selectedOptions.length > 0 && !this.opened + ? inputSelectedOptions + : html``} + ${!this.opened + ? html`+${this._additionalSelectedOptionCount}` + : ""} + +
+ ${this.opened ? (this.searchBarLoadingState ? searchLoadingIcon : searchMagIcon) : ""} ${removeButton}
`; + + return this.searchBar + ? search + : html`
0, + })} + tabindex="${this.disabled ? "-1" : 0}" + ?autofocus=${this.autofocus} + @click=${this.togglePopover} + role="button" + aria-haspopup="listbox" + aria-expanded="${this.opened}" + aria-labelledby="label" + > + ${this.label} + ${this.placeholder} + ${this.label} + ${inputSelectedOptions} + +${this._additionalSelectedOptionCount} +
+ ${removeButton} + + + +
+
`; } selectAllTemplate() { @@ -403,6 +500,20 @@ export default class BlSelect extends Form > ${this.selectAllTemplate()} + ${this.searchBar && this.noResultFound + ? html`
+ ${this.searchNotFoundText} + { + this._handleSearchOptions({ target: { value: "" } } as InputEvent & { + target: HTMLInputElement; + }); + }} + >${this.popoverClearSearchText} +
` + : ""}
${invalidMessage} ${helpMessage}
`; @@ -453,6 +564,44 @@ export default class BlSelect extends Form ); } + private _handleSearchEvent() { + this._onBlSearch(this._searchText); + } + + private _handleSearchOptions(e: InputEvent): void { + if (!this.searchBar) return; + + this._searchText = (e.target as HTMLInputElement).value.toLowerCase(); + + this._handleSearchEvent(); + + this._connectedOptions.forEach(option => { + const isVisible = option.textContent?.toLowerCase().includes(this._searchText); + + option.hidden = !isVisible; + }); + + this._selectedOptions = this.options.filter(option => option.selected); + + this._handleLastVisibleSearchedOption(); + + this.requestUpdate(); + } + + private _handleLastVisibleSearchedOption() { + const lastVisibleOption = [...this.options].reverse().find(option => !option.hidden); + + if (lastVisibleOption) { + lastVisibleOption?.shadowRoot?.querySelector("div")?.classList.add("no-border-bottom"); + } + + this.options.map(option => { + if (!option.hidden && option !== lastVisibleOption) { + option.shadowRoot?.querySelector("div")?.classList.remove("no-border-bottom"); + } + }); + } + private _handleSingleSelect(optionItem: BlSelectOption) { this.value = optionItem.value; diff --git a/src/components/select/option/bl-select-option.css b/src/components/select/option/bl-select-option.css index 83d55bf5..fe557bfd 100644 --- a/src/components/select/option/bl-select-option.css +++ b/src/components/select/option/bl-select-option.css @@ -22,6 +22,10 @@ border-bottom: var(--option-separator); } +.no-border-bottom::after { + border-bottom: none; +} + :host(:last-of-type) .option-container::after { border-bottom: none; } @@ -70,4 +74,4 @@ display: block; color: var(--option-color); padding: var(--option-spacing); -} +} \ No newline at end of file