From f5e4cb71f13247ae979b199920978d8475b5bd14 Mon Sep 17 00:00:00 2001 From: allan-chagas-brisa Date: Thu, 5 Oct 2023 11:16:05 -0300 Subject: [PATCH 01/11] feat: input select initial commit --- .../ion/src/lib/core/types/input-select.ts | 24 +++ .../input-select/input-select.component.html | 60 ++++++ .../input-select/input-select.component.scss | 84 ++++++++ .../input-select.component.spec.ts | 197 ++++++++++++++++++ .../input-select/input-select.component.ts | 80 +++++++ .../lib/input-select/input.select.module.ts | 12 ++ .../ion/src/lib/input/input.component.html | 2 + .../ion/src/lib/input/input.component.scss | 58 +++--- projects/ion/src/lib/ion.module.ts | 3 + projects/ion/src/public-api.ts | 1 + stories/InputSelect.stories.ts | 44 ++++ 11 files changed, 536 insertions(+), 29 deletions(-) create mode 100644 projects/ion/src/lib/core/types/input-select.ts create mode 100644 projects/ion/src/lib/input-select/input-select.component.html create mode 100644 projects/ion/src/lib/input-select/input-select.component.scss create mode 100644 projects/ion/src/lib/input-select/input-select.component.spec.ts create mode 100644 projects/ion/src/lib/input-select/input-select.component.ts create mode 100644 projects/ion/src/lib/input-select/input.select.module.ts create mode 100644 stories/InputSelect.stories.ts diff --git a/projects/ion/src/lib/core/types/input-select.ts b/projects/ion/src/lib/core/types/input-select.ts new file mode 100644 index 000000000..378b20f3a --- /dev/null +++ b/projects/ion/src/lib/core/types/input-select.ts @@ -0,0 +1,24 @@ +import { EventEmitter } from '@angular/core'; +import { DropdownItem } from './dropdown'; + +export type ValueToEmmit = { + optionSelected: SelectOption; + inputValue: string; + secondValue?: string; +}; + +export interface SelectOption extends DropdownItem { + multiple?: boolean; +} + +export interface IonInputSelectProps { + name: string; + value?: string; + secondValue?: string; + disabled?: boolean; + selectOptions?: SelectOption[]; + singlePlaceholder?: string; + firstPlaceholder?: string; + secondPlaceholder?: string; + valueChange?: EventEmitter; +} diff --git a/projects/ion/src/lib/input-select/input-select.component.html b/projects/ion/src/lib/input-select/input-select.component.html new file mode 100644 index 000000000..a0d4fcd40 --- /dev/null +++ b/projects/ion/src/lib/input-select/input-select.component.html @@ -0,0 +1,60 @@ +
+
+ + +
+
+ +
+
+
-
+
+
+ +
+
+
+
diff --git a/projects/ion/src/lib/input-select/input-select.component.scss b/projects/ion/src/lib/input-select/input-select.component.scss new file mode 100644 index 000000000..7c1b73026 --- /dev/null +++ b/projects/ion/src/lib/input-select/input-select.component.scss @@ -0,0 +1,84 @@ +@import '../input/input.component.scss'; +@import '../../styles/index.scss'; + +.input-wraper { + display: flex; + flex-direction: column; + gap: 4px; + + .input-select-container { + display: flex; + + .input { + border-radius: 0 8px 8px 0; + } + } +} + +.dropdown-container { + &__button { + background-color: $neutral-2; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + min-width: max-content; + border-radius: 8px 0px 0px 8px; + border: 1px solid $neutral-5; + @include font-size-sm; + font-weight: 400; + color: $neutral-7; + + border-right: none; + } +} + +.multiple-input { + .first-input { + border-right: none; + border-radius: 0 !important; + + &:hover { + border: 1px solid $primary-4; + } + + &:focus-within { + border: 1px solid $primary-4; + @include add-colors($primary-5, 2px solid, $primary-2); + } + + &:active { + border: 1px solid $primary-4; + @include add-colors($primary-5, 2px solid, $primary-2); + } + } + + .second-input { + border-left: none; + + &:hover { + border: 1px solid $primary-4; + } + + &:focus-within { + border: 1px solid $primary-4; + @include add-colors($primary-5, 2px solid, $primary-2); + } + + &:active { + border: 1px solid $primary-4; + @include add-colors($primary-5, 2px solid, $primary-2); + } + } +} + +.separator { + position: relative; + z-index: -1; + background: $neutral-1; + border-top: 1px solid $neutral-5; + border-bottom: 1px solid $neutral-5; + padding: spacing(1) spacing(1.5); + color: $neutral-5; +} diff --git a/projects/ion/src/lib/input-select/input-select.component.spec.ts b/projects/ion/src/lib/input-select/input-select.component.spec.ts new file mode 100644 index 000000000..3f4693e26 --- /dev/null +++ b/projects/ion/src/lib/input-select/input-select.component.spec.ts @@ -0,0 +1,197 @@ +import userEvent from '@testing-library/user-event'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IonSharedModule } from '../shared.module'; +import { + IonInputSelectProps, + SelectOption, + ValueToEmmit, +} from './../core/types/input-select'; +import { + IonInputSelectComponent, + defaultSelectOptions, +} from './input-select.component'; +import { fireEvent, render, screen } from '@testing-library/angular'; +import { ComponentFixture } from '@angular/core/testing'; +import { SafeAny } from '../utils/safe-any'; + +const resetComponentState = (): void => { + const selectButton = screen.getByTestId('ion-select-button'); + fireEvent.click(selectButton); + const firstOption = document.getElementById('option-0'); + if (firstOption) { + fireEvent.click(firstOption); + } +}; + +const selectOptionWithmultple = (): void => { + const selectButton = screen.getByTestId('ion-select-button'); + fireEvent.click(selectButton); + const secondOption = document.getElementById('option-1'); + fireEvent.click(secondOption); +}; + +const sut = async ( + customProps?: IonInputSelectProps +): Promise> => { + const { fixture } = await render(IonInputSelectComponent, { + componentProperties: customProps, + imports: [CommonModule, FormsModule, IonSharedModule], + declarations: [IonInputSelectComponent], + }); + + return fixture; +}; + +describe('IonInputSelectComponent', () => { + afterEach(() => { + resetComponentState(); + }); + + it('should render the input select', async () => { + await sut(); + const inputSelect = screen.getByTestId('ion-input-select'); + expect(inputSelect).toBeVisible(); + }); + + it('should render the select button', async () => { + await sut(); + const selectButton = screen.getByTestId('ion-select-button'); + expect(selectButton).toBeVisible(); + }); + + it('should render without the dropdown', async () => { + await sut(); + const dropdown = screen.queryByTestId('ion-dropdown'); + expect(dropdown).not.toBeInTheDocument(); + }); + + it('should open the dropdown when the select button is clicked', async () => { + await sut(); + const selectButton = screen.getByTestId('ion-select-button'); + fireEvent.click(selectButton); + const dropdown = screen.getByTestId('ion-dropdown'); + expect(dropdown).toBeVisible(); + }); + + it('should close the dropdown when clicking outside', async () => { + await sut(); + const selectButton = screen.getByTestId('ion-select-button'); + fireEvent.click(selectButton); + fireEvent.click(document.body); + const dropdown = screen.queryByText('ion-dropdown'); + expect(dropdown).not.toBeInTheDocument(); + }); + + describe('IonInputSelectComponent - Event emission', () => { + const valueChange = jest.fn(); + const value = 'input'; + let valueToEmmit: ValueToEmmit = { + optionSelected: { + label: 'Maior que', + selected: true, + }, + inputValue: value, + secondValue: '', + }; + + afterEach(() => { + valueChange.mockClear(); + }); + + it('should emit the option selected and the input value on input', async () => { + await sut({ + name: 'test', + valueChange: { emit: valueChange } as SafeAny, + }); + const singleInput = screen.getByTestId('single-input'); + userEvent.type(singleInput, value); + expect(valueChange).toHaveBeenCalledWith(valueToEmmit); + }); + + it('should emit the option selected and the input value on input', async () => { + await sut({ + name: 'test', + valueChange: { emit: valueChange } as SafeAny, + }); + valueToEmmit = { + optionSelected: { + label: 'Entre', + selected: true, + multiple: true, + }, + inputValue: value, + secondValue: value, + }; + + selectOptionWithmultple(); + const singleInput = screen.getByTestId('single-input'); + userEvent.type(singleInput, value); + const secondInput = screen.getByTestId('second-input'); + userEvent.type(secondInput, value); + + expect(valueChange).toHaveBeenCalledWith(valueToEmmit); + }); + }); + + describe('IonInputSelectComponent - Default options', () => { + it('should render the first option as the default button label', async () => { + await sut(); + const selectButton = screen.getByTestId('ion-select-button'); + expect(selectButton.textContent.trim()).toBe( + defaultSelectOptions[0].label + ); + }); + + it('should change the button label', async () => { + await sut(); + const selectButton = screen.getByTestId('ion-select-button'); + fireEvent.click(selectButton); + const secondOption = document.getElementById('option-1'); + fireEvent.click(secondOption); + expect(selectButton.textContent.trim()).toBe( + defaultSelectOptions[1].label + ); + expect(screen.getByTestId('second-input')).toBeVisible(); + }); + + it('should render only the single input when the option is not multiple', async () => { + await sut(); + const singleInput = screen.getByTestId('single-input'); + const secondInput = screen.queryByTestId('second-input'); + expect(singleInput).toBeVisible(); + expect(secondInput).not.toBeInTheDocument(); + }); + + it('should render both inputs when the option is multiple', async () => { + await sut(); + selectOptionWithmultple(); + const singleInput = screen.getByTestId('single-input'); + const secondInput = screen.getByTestId('second-input'); + expect(singleInput).toBeVisible(); + expect(secondInput).toBeVisible(); + }); + }); + + describe('IonInputSelectComponent - Custom options', () => { + const customSelectOptions: SelectOption[] = [ + { + label: 'Acima de', + multiple: true, + }, + { + label: 'Abaixo de', + }, + ]; + + it('should render select button with the label from the informed options', async () => { + await sut({ + name: 'test', + selectOptions: customSelectOptions, + }); + expect( + screen.getByText(customSelectOptions[0].label) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/projects/ion/src/lib/input-select/input-select.component.ts b/projects/ion/src/lib/input-select/input-select.component.ts new file mode 100644 index 000000000..4a5364eaf --- /dev/null +++ b/projects/ion/src/lib/input-select/input-select.component.ts @@ -0,0 +1,80 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { SelectOption, ValueToEmmit } from '../core/types/input-select'; + +export const defaultSelectOptions: SelectOption[] = [ + { + label: 'Maior que', + }, + { + label: 'Entre', + multiple: true, + }, + { + label: 'Igual a', + }, + { + label: 'Maior ou igual a', + }, + { + label: 'Menor que', + }, + { + label: 'Menor ou igual a', + }, +]; + +@Component({ + selector: 'ion-input-select', + templateUrl: './input-select.component.html', + styleUrls: ['./input-select.component.scss'], +}) +export class IonInputSelectComponent implements OnInit { + @Input() name: string; + @Input() disabled = false; + @Input() singlePlaceholder = 'Digite o valor'; + @Input() firstPlaceholder = 'Valor inicial'; + @Input() secondPlaceholder = 'Valor final'; + @Input() value = ''; + @Input() secondValue = ''; + @Input() selectOptions: SelectOption[] = defaultSelectOptions; + @Output() valueChange = new EventEmitter(); + + public dropdownVisible = false; + public currentOption: SelectOption; + + public handleSelect(selectedOption: SelectOption[]): void { + this.currentOption = selectedOption[0]; + this.toggleDropdownVisibility(); + } + + public handleClick(): void { + this.toggleDropdownVisibility(); + } + + public handleChange(): void { + const valueToEmmit = { + optionSelected: this.currentOption, + inputValue: this.value, + secondValue: this.secondValue, + }; + + this.valueChange.emit(valueToEmmit); + } + + public onClickOutside(): void { + this.dropdownVisible = false; + } + + ngOnInit(): void { + this.selectOptions[0].selected = true; + this.currentOption = this.getCurrentOption(); + } + + private toggleDropdownVisibility(): void { + this.dropdownVisible = !this.dropdownVisible; + } + + private getCurrentOption(): SelectOption { + return this.selectOptions.filter((option) => option.selected)[0]; + } +} diff --git a/projects/ion/src/lib/input-select/input.select.module.ts b/projects/ion/src/lib/input-select/input.select.module.ts new file mode 100644 index 000000000..e24f7ba3f --- /dev/null +++ b/projects/ion/src/lib/input-select/input.select.module.ts @@ -0,0 +1,12 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { IonInputSelectComponent } from './input-select.component'; +import { IonSharedModule } from '../shared.module'; +import { FormsModule } from '@angular/forms'; + +@NgModule({ + declarations: [IonInputSelectComponent], + imports: [CommonModule, FormsModule, IonSharedModule], + exports: [IonInputSelectComponent], +}) +export class IonInputSelectModule {} diff --git a/projects/ion/src/lib/input/input.component.html b/projects/ion/src/lib/input/input.component.html index 99cb6f903..20355f173 100644 --- a/projects/ion/src/lib/input/input.component.html +++ b/projects/ion/src/lib/input/input.component.html @@ -61,6 +61,7 @@