Skip to content

Commit

Permalink
Merge pull request #424 from IQSS/feature/420-select-single
Browse files Browse the repository at this point in the history
Feature/420 Select Advanced (single and multiple selection with search)
  • Loading branch information
GPortas authored Aug 1, 2024
2 parents 55cd353 + 3eaf7bb commit e612dca
Show file tree
Hide file tree
Showing 28 changed files with 2,245 additions and 1,018 deletions.
4 changes: 1 addition & 3 deletions packages/design-system/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
- **FormFeedback:** remove `span: 9` from styles.
- **FormGroup:** controlId is now optional.
- **FormLabel:** extend Props Interface to accept `htmlFor` prop.
- **SelectMultiple:** NEW multiple selector for occasions when you can choose more than one option.
- **FormSelectMultiple:** The new multiple selector is added to the "FormGroup" components.
- **DropdownButton:** extend Props Interface to accept `ariaLabel` prop.
- **DropdownButtonItem:** extend Props Interface to accept `as` prop.
Expand All @@ -31,14 +30,13 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
- **FormInput:** remove withinMultipleFieldsGroup prop.
- **FormLabel:** remove withinMultipleFieldsGroup prop extend interface to accept ColProps.
- **FormSelect:** remove withinMultipleFieldsGroup prop.
- **FormSelectMultiple:** remove withinMultipleFieldsGroup prop.
- **FormText:** remove withinMultipleFieldsGroup prop.
- **FormTextArea:** remove withinMultipleFieldsGroup prop.
- **FormInputGroup:** remove hasVisibleLabel prop.
- **FormInputGroupText:** refactor type.
- **Select Multiple:** add is-invalid classname if isInvalid prop is true.
- **Card:** NEW card element to show header and body.
- **ProgressBar:** NEW progress bar element to show progress.
- **SelectAdvanced:** NEW ehanced select to search across options, and perform both single and multiple selections.
- **NavbarDropdownItem:** Now accepts `as` prop and takes `as` Element props.
- **FormInputGroup:** extend Props Interface to accept `hasValidation` prop to properly show rounded corners in an <InputGroup> with validation
- **Button:** extend Props Interface to accept `size` prop.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Col, ColProps } from '../../grid/Col'
import { Row } from '../../grid/Row'
import { FormCheckbox } from './form-element/FormCheckbox'
import { FormFeedback } from './form-element/FormFeedback'
import { FormSelectMultiple } from './form-element/FormSelectMultiple'
import { FormSelectAdvanced } from './form-element/FormSelectAdvanced'

interface FormGroupProps extends ColProps {
as?: typeof Col | typeof Row
Expand All @@ -27,7 +27,7 @@ function FormGroup({ as = Row, controlId, children, ...props }: PropsWithChildre
FormGroup.Label = FormLabel
FormGroup.Input = FormInput
FormGroup.Select = FormSelect
FormGroup.SelectMultiple = FormSelectMultiple
FormGroup.SelectAdvanced = FormSelectAdvanced
FormGroup.TextArea = FormTextArea
FormGroup.Text = FormText
FormGroup.Checkbox = FormCheckbox
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { PropsWithChildren, forwardRef } from 'react'
import { SelectAdvanced, SelectAdvancedProps } from '../../../select-advanced/SelectAdvanced'

export type FormSelectAdvancedProps = SelectAdvancedProps & {
inputButtonId: string
isInvalid?: boolean
}

export const FormSelectAdvanced = forwardRef(
({ ...props }: PropsWithChildren<FormSelectAdvancedProps>, ref) => {
return <SelectAdvanced ref={ref as React.ForwardedRef<HTMLInputElement>} {...props} />
}
)

FormSelectAdvanced.displayName = 'FormSelectAdvanced'

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
@import 'src/lib/assets/styles/design-tokens/typography.module';

:root {
--select-multiple-menu-max-height: 300px;
--select-advanced-menu-max-height: 300px;
--toggle-padding: 6px 36px 6px 12px;
}

.select-multiple-toggle {
.select-advanced-toggle {
position: relative;
display: grid;
background-color: #fff;
Expand Down Expand Up @@ -77,7 +77,7 @@
padding: var(--toggle-padding);
pointer-events: none;

.selected-options-container {
.multiple-selected-options-container {
display: inline-flex;
flex: 1;
flex-wrap: wrap;
Expand Down Expand Up @@ -105,13 +105,17 @@
}
}
}

.single-selected-option {
margin: 0;
}
}
}

.select-multiple-menu {
.select-advanced-menu {
width: 100%;
max-width: 100%;
max-height: var(--select-multiple-menu-max-height);
max-height: var(--select-advanced-menu-max-height);
padding-top: 0;
overflow-x: hidden;
overflow-y: auto;
Expand Down Expand Up @@ -149,11 +153,27 @@
}

&__checkbox-input {
padding-block: 0.25rem;
display: flex;
align-items: center;
padding-left: 0;

input[type='checkbox'] {
float: unset;
margin-top: 0;
margin-left: 0;
}

label {
width: 100%;
padding-left: 0.5rem;
padding-block: 0.25rem;
}
}
}

.option-item-not-multiple {
margin-bottom: 0.125rem;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { useEffect, useMemo, useId, useReducer, forwardRef, ForwardedRef, useCallback } from 'react'
import { Dropdown as DropdownBS } from 'react-bootstrap'
import {
selectAdvancedReducer,
selectOption,
removeOption,
selectAllOptions,
deselectAllOptions,
searchOptions,
getSelectAdvancedInitialState,
updateOptions
} from './selectAdvancedReducer'
import { SelectAdvancedToggle } from './SelectAdvancedToggle'
import { SelectAdvancedMenu } from './SelectAdvancedMenu'
import { areArraysEqual, debounce } from './utils'
import { useIsFirstRender } from './useIsFirstRender'

export const DEFAULT_LOCALES = {
select: 'Select...'
}

export const SELECT_MENU_SEARCH_DEBOUNCE_TIME = 400

export type SelectAdvancedProps =
| {
isMultiple?: false
options: string[]
onChange?: (selected: string) => void
defaultValue?: string
isSearchable?: boolean
isDisabled?: boolean
isInvalid?: boolean
inputButtonId?: string
locales?: {
select?: string
}
}
| {
isMultiple: true
options: string[]
onChange?: (selected: string[]) => void
defaultValue?: string[]
isSearchable?: boolean
isDisabled?: boolean
isInvalid?: boolean
inputButtonId?: string
locales?: {
select?: string
}
}

export const SelectAdvanced = forwardRef(
(
{
options: propsOption,
onChange,
defaultValue,
isMultiple,
isSearchable = true,
isDisabled = false,
isInvalid = false,
inputButtonId,
locales
}: SelectAdvancedProps,
ref: ForwardedRef<HTMLInputElement | null>
) => {
const dynamicInitialOptions = useMemo(() => {
return isMultiple ? propsOption : [locales?.select ?? DEFAULT_LOCALES.select, ...propsOption]
}, [isMultiple, propsOption, locales])

const [{ selected, filteredOptions, searchValue, options }, dispatch] = useReducer(
selectAdvancedReducer,
getSelectAdvancedInitialState(
Boolean(isMultiple),
dynamicInitialOptions,
locales?.select ?? DEFAULT_LOCALES.select,
defaultValue
)
)

const isFirstRender = useIsFirstRender()
const menuId = useId()

const callOnChage = useCallback(
(newSelected: string | string[]): void => {
if (!onChange) return
//@ts-expect-error - types differs
onChange(newSelected)
},
[onChange]
)

useEffect(() => {
const optionsRemainTheSame = areArraysEqual(dynamicInitialOptions, options)

// If the options remain the same, do nothing
if (optionsRemainTheSame) return

const selectedOptionsThatAreNotInNewOptions = isMultiple
? (selected as string[]).filter((option) => !dynamicInitialOptions.includes(option))
: []

// If there are selected options that are not in the new options, remove them
if (isMultiple && selectedOptionsThatAreNotInNewOptions.length > 0) {
selectedOptionsThatAreNotInNewOptions.forEach((option) => dispatch(removeOption(option)))

const newSelected = (selected as string[]).filter((option) =>
dynamicInitialOptions.includes(option)
)

callOnChage(newSelected)
}

// If the selected option is not in the new options replace it with the default empty value
if (
!isMultiple &&
selected !== '' &&
!dynamicInitialOptions.some((option) => option === (selected as string))
) {
dispatch(selectOption(''))
callOnChage('')
}
dispatch(updateOptions(dynamicInitialOptions))
}, [dynamicInitialOptions, options, selected, isFirstRender, dispatch, callOnChage, isMultiple])

const handleSearch = debounce((e: React.ChangeEvent<HTMLInputElement>): void => {
const { value } = e.target
dispatch(searchOptions(value))
}, SELECT_MENU_SEARCH_DEBOUNCE_TIME)

// ONLY FOR MULTIPLE SELECT 👇
const handleCheck = (e: React.ChangeEvent<HTMLInputElement>): void => {
const { value, checked } = e.target

if (checked) {
const newSelected = [...(selected as string[]), value]
callOnChage(newSelected)

dispatch(selectOption(value))
} else {
const newSelected = (selected as string[]).filter((option) => option !== value)
callOnChage(newSelected)

dispatch(removeOption(value))
}
}

// ONLY FOR SINGLE SELECT 👇
const handleClickOption = (option: string): void => {
if ((selected as string) === option) {
return
}
callOnChage(option)

dispatch(selectOption(option))
}

// ONLY FOR MULTIPLE SELECT 👇
const handleRemoveSelectedOption = (option: string): void => {
const newSelected = (selected as string[]).filter((selected) => selected !== option)
callOnChage(newSelected)

dispatch(removeOption(option))
}

// ONLY FOR MULTIPLE SELECT 👇
const handleToggleAllOptions = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (e.target.checked) {
const newSelected =
filteredOptions.length > 0
? Array.from(new Set([...(selected as string[]), ...filteredOptions]))
: options

callOnChage(newSelected)

dispatch(selectAllOptions())
} else {
const newSelected =
filteredOptions.length > 0
? (selected as string[]).filter((option) => !filteredOptions.includes(option))
: []

callOnChage(newSelected)

dispatch(deselectAllOptions())
}
}

return (
<DropdownBS
autoClose={isMultiple ? 'outside' : true}
className={isInvalid ? 'is-invalid' : ''}>
<SelectAdvancedToggle
isMultiple={Boolean(isMultiple)}
selected={selected}
handleRemoveSelectedOption={handleRemoveSelectedOption}
isInvalid={isInvalid}
isDisabled={isDisabled}
inputButtonId={inputButtonId}
menuId={menuId}
selectWord={locales?.select ?? DEFAULT_LOCALES.select}
ref={ref}
/>
<SelectAdvancedMenu
isMultiple={Boolean(isMultiple)}
options={options}
selected={selected}
filteredOptions={filteredOptions}
searchValue={searchValue}
handleToggleAllOptions={handleToggleAllOptions}
handleSearch={handleSearch}
handleCheck={handleCheck}
handleClickOption={handleClickOption}
isSearchable={isSearchable}
menuId={menuId}
selectWord={locales?.select ?? DEFAULT_LOCALES.select}
/>
</DropdownBS>
)
}
)

SelectAdvanced.displayName = 'SelectAdvanced'
Loading

0 comments on commit e612dca

Please sign in to comment.