agroptima-design-system 0.27.15 → 0.27.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agroptima-design-system",
3
- "version": "0.27.15",
3
+ "version": "0.27.16",
4
4
  "scripts": {
5
5
  "dev": "npm run storybook",
6
6
  "storybook": "storybook dev -p 6006 --ci",
@@ -4,8 +4,10 @@ import { Icon } from './Icon'
4
4
  import { IconButton } from './Button'
5
5
  import { classNames } from '../utils/classNames'
6
6
  import { buildHelpText } from '../utils/buildHelpText'
7
- import { useOutsideClick } from '../utils/useOutsideClick'
8
- import { useOpen } from '../utils/useOpen'
7
+ import { useOutsideClick } from '../hooks/useOutsideClick'
8
+ import { useOpen } from '../hooks/useOpen'
9
+ import { Input } from './Input'
10
+ import { useSearch } from '../hooks/useSearch'
9
11
 
10
12
  export type Variant = 'primary'
11
13
  export type Option = { id: string; label: string }
@@ -27,6 +29,8 @@ export interface MultiselectProps extends InputPropsWithoutOnChange {
27
29
  hideLabel?: boolean
28
30
  defaultValue?: string[]
29
31
  onChange?: (value: string[]) => void
32
+ isSearchable?: boolean
33
+ searchLabel?: string
30
34
  }
31
35
 
32
36
  export function Multiselect({
@@ -44,6 +48,8 @@ export function Multiselect({
44
48
  selectedLabel = 'items selected',
45
49
  hideLabel = false,
46
50
  defaultValue = [],
51
+ isSearchable = false,
52
+ searchLabel = 'Search',
47
53
  ...props
48
54
  }: MultiselectProps): React.JSX.Element {
49
55
  const helpTexts = buildHelpText(helpText, errors)
@@ -108,18 +114,15 @@ export function Multiselect({
108
114
  visible={hasSelectedOptions}
109
115
  />
110
116
  </div>
111
- {isOpen && (
112
- <ul className="select-options" role="listbox">
113
- {options.map((option) => (
114
- <Option
115
- key={`${name}-${option.id}`}
116
- option={option}
117
- selectedOptions={selectedOptions}
118
- onSelect={selectOption}
119
- />
120
- ))}
121
- </ul>
122
- )}
117
+
118
+ <OptionList
119
+ isOpen={isOpen}
120
+ options={options}
121
+ selectedOptions={selectedOptions}
122
+ onSelect={selectOption}
123
+ isSearchable={isSearchable}
124
+ searchLabel={searchLabel}
125
+ />
123
126
  </div>
124
127
  {helpTexts.map((helpText) => (
125
128
  <span key={`${name}-${helpText}`} className="select-help-text">
@@ -136,26 +139,80 @@ export function Multiselect({
136
139
  )
137
140
  }
138
141
 
139
- interface OptionProps {
140
- option: Option
142
+ interface OptionListProps {
143
+ isSearchable: boolean
144
+ searchLabel: string
145
+ options: Option[]
141
146
  selectedOptions: string[]
142
147
  onSelect: (id: string) => void
148
+ isOpen: boolean
149
+ }
150
+
151
+ function OptionList({
152
+ options,
153
+ selectedOptions,
154
+ onSelect,
155
+ isSearchable,
156
+ searchLabel,
157
+ isOpen,
158
+ }: OptionListProps) {
159
+ const { findItems, search } = useSearch(options, 'label')
160
+ if (!isOpen) return null
161
+
162
+ return (
163
+ <div className="select-options">
164
+ {isSearchable && (
165
+ <Input
166
+ label={searchLabel}
167
+ hideLabel
168
+ onChange={(e) => search(e.target.value)}
169
+ placeholder={searchLabel}
170
+ icon="Search"
171
+ className="search"
172
+ />
173
+ )}
174
+ <ul role="listbox">
175
+ <Option
176
+ options={findItems}
177
+ selectedOptions={selectedOptions}
178
+ onSelect={onSelect}
179
+ />
180
+ </ul>
181
+ </div>
182
+ )
183
+ }
184
+
185
+ interface OptionProps {
186
+ options: Option[]
187
+ onSelect: (id: string) => void
188
+ selectedOptions: string[]
143
189
  }
144
190
 
145
- function Option({ option, selectedOptions, onSelect }: OptionProps) {
146
- const isOptionSelected = selectedOptions.includes(option.id)
147
- const icon = isOptionSelected ? 'CheckboxActive' : 'CheckboxInactive'
191
+ function Option({ options, onSelect, selectedOptions }: OptionProps) {
192
+ function isSelected(id: string): boolean {
193
+ return selectedOptions.includes(id)
194
+ }
195
+ function getIcon(id: string) {
196
+ return isSelected(id) ? 'CheckboxActive' : 'CheckboxInactive'
197
+ }
148
198
  return (
149
- <li
150
- role="option"
151
- className="option"
152
- tabIndex={0}
153
- aria-selected={isOptionSelected}
154
- data-option={option}
155
- onClick={() => onSelect(option.id)}
156
- >
157
- <Icon name={icon} />
158
- {option.label}
159
- </li>
199
+ <>
200
+ {options.map((option) => {
201
+ return (
202
+ <li
203
+ key={option.id}
204
+ role="option"
205
+ className="option"
206
+ tabIndex={0}
207
+ aria-selected={isSelected(option.id)}
208
+ data-option={option}
209
+ onClick={() => onSelect(option.id)}
210
+ >
211
+ <Icon name={getIcon(option.id)} />
212
+ {option.label}
213
+ </li>
214
+ )
215
+ })}
216
+ </>
160
217
  )
161
218
  }
@@ -1,7 +1,7 @@
1
1
  'use client'
2
- import { useOutsideClick } from '../../utils/useOutsideClick'
2
+ import { useOutsideClick } from '../../hooks/useOutsideClick'
3
3
  import { classNames } from '../../utils/classNames'
4
- import { useOpen } from '../../utils/useOpen'
4
+ import { useOpen } from '../../hooks/useOpen'
5
5
  import { useRef } from 'react'
6
6
  import './Popover.scss'
7
7
 
@@ -74,10 +74,6 @@
74
74
  }
75
75
 
76
76
  .select-options {
77
- max-height: 256px;
78
- overflow-y: auto;
79
- overflow-anchor: none;
80
- z-index: depth.$z-dropdown-options;
81
77
  border-radius: config.$corner-radius-xxs;
82
78
  background: color_alias.$neutral-white;
83
79
  box-shadow:
@@ -132,8 +128,11 @@
132
128
  height: config.$icon-size-3x;
133
129
  }
134
130
  }
135
-
131
+
136
132
  .select-options {
133
+ max-height: 256px;
134
+ overflow-y: auto;
135
+ overflow-anchor: none;
137
136
  z-index: depth.$z-dropdown-options;
138
137
  margin: 0;
139
138
  padding: config.$space-1x 0rem;
@@ -141,6 +140,16 @@
141
140
  position: absolute;
142
141
  width: 100%;
143
142
 
143
+ .search {
144
+ margin: config.$space-2x config.$space-3x;
145
+ }
146
+
147
+ ul {
148
+ width: 100%;
149
+ margin: 0;
150
+ padding: 0;
151
+ }
152
+
144
153
  .option {
145
154
  display: flex;
146
155
  align-items: center;
@@ -154,5 +163,5 @@
154
163
  margin-right: config.$space-1x;
155
164
  }
156
165
  }
157
- }
166
+ }
158
167
  }
@@ -4,9 +4,11 @@ import { Icon } from './Icon'
4
4
  import { IconButton } from './Button'
5
5
  import { classNames } from '../utils/classNames'
6
6
  import { buildHelpText } from '../utils/buildHelpText'
7
- import { useOutsideClick } from '../utils/useOutsideClick'
8
- import { useOpen } from '../utils/useOpen'
7
+ import { useOutsideClick } from '../hooks/useOutsideClick'
8
+ import { useOpen } from '../hooks/useOpen'
9
9
  import './Select.scss'
10
+ import { Input } from './Input'
11
+ import { useSearch } from '../hooks/useSearch'
10
12
 
11
13
  export type Variant = 'primary'
12
14
  export type Option = { id: string; label: string }
@@ -27,6 +29,8 @@ export interface SelectProps extends InputPropsWithoutOnChange {
27
29
  defaultValue?: string
28
30
  onChange?: (value: string) => void
29
31
  required?: boolean
32
+ isSearchable: boolean
33
+ searchLabel?: string
30
34
  }
31
35
 
32
36
  const EMPTY_OPTION = { id: '', label: '' }
@@ -46,6 +50,8 @@ export function Select({
46
50
  onChange = () => {},
47
51
  defaultValue,
48
52
  required = false,
53
+ isSearchable = false,
54
+ searchLabel = 'Search',
49
55
  ...props
50
56
  }: SelectProps): React.JSX.Element {
51
57
  const helpTexts = buildHelpText(helpText, errors)
@@ -110,14 +116,16 @@ export function Select({
110
116
  visible={!isEmpty}
111
117
  />
112
118
  </div>
113
- {isOpen && (
114
- <OptionList
115
- options={options}
116
- selectedOption={selectedOption}
117
- selectOption={selectOption}
118
- onClick={close}
119
- />
120
- )}
119
+
120
+ <OptionList
121
+ isOpen={isOpen}
122
+ options={options}
123
+ selectedOption={selectedOption}
124
+ selectOption={selectOption}
125
+ onClick={close}
126
+ isSearchable={isSearchable}
127
+ searchLabel={searchLabel}
128
+ />
121
129
  </div>
122
130
  {helpTexts.map((helpText) => (
123
131
  <span key={`${name}-${helpText}`} className="select-help-text">
@@ -140,6 +148,9 @@ interface OptionListProps {
140
148
  selectedOption: Option
141
149
  selectOption: (option: Option) => void
142
150
  onClick: () => void
151
+ isSearchable: boolean
152
+ searchLabel: string
153
+ isOpen: boolean
143
154
  }
144
155
 
145
156
  function OptionList({
@@ -147,9 +158,44 @@ function OptionList({
147
158
  selectedOption,
148
159
  selectOption,
149
160
  onClick,
161
+ isSearchable,
162
+ searchLabel,
163
+ isOpen,
150
164
  }: OptionListProps) {
165
+ const { findItems, search } = useSearch(options, 'label')
166
+ if (!isOpen) return null
167
+
168
+ return (
169
+ <div className="select-options">
170
+ {isSearchable && (
171
+ <Input
172
+ label={searchLabel}
173
+ hideLabel
174
+ onChange={(e) => search(e.target.value)}
175
+ placeholder={searchLabel}
176
+ icon="Search"
177
+ className="search"
178
+ />
179
+ )}
180
+ <ul role="listbox" onClick={onClick}>
181
+ <Option
182
+ options={findItems}
183
+ selectedOption={selectedOption}
184
+ selectOption={selectOption}
185
+ />
186
+ </ul>
187
+ </div>
188
+ )
189
+ }
190
+ interface OptionProps {
191
+ options: Option[]
192
+ selectedOption: Option
193
+ selectOption: (option: Option) => void
194
+ }
195
+
196
+ function Option({ options, selectedOption, selectOption }: OptionProps) {
151
197
  return (
152
- <ul className="select-options" role="listbox" onClick={onClick}>
198
+ <>
153
199
  {options.map((option) => {
154
200
  return (
155
201
  <li
@@ -165,6 +211,6 @@ function OptionList({
165
211
  </li>
166
212
  )
167
213
  })}
168
- </ul>
214
+ </>
169
215
  )
170
216
  }
@@ -0,0 +1,14 @@
1
+ import { useState } from 'react'
2
+ import { normalizeSearch } from '../utils/normalizeSearch'
3
+
4
+ export function useSearch(items: any[], key: string) {
5
+ const [findItems, setFindItems] = useState<any[]>(items)
6
+
7
+ function search(term: string) {
8
+ const filteredList = items.filter((option) => {
9
+ return normalizeSearch(option[key]).includes(normalizeSearch(term))
10
+ })
11
+ setFindItems(filteredList)
12
+ }
13
+ return { findItems, search }
14
+ }
@@ -4,6 +4,10 @@ import { Meta } from "@storybook/blocks";
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## 0.27.16
8
+
9
+ * Add optional search to Select and MultiSelect component.
10
+
7
11
  ## 0.27.15
8
12
 
9
13
  * Update `pointer-events` to `auto` on Alert component.
@@ -41,6 +41,12 @@ const meta = {
41
41
  description:
42
42
  'Optional array of errors. If passed, the errors are listed and invalid style is applied.',
43
43
  },
44
+ isSearchable: {
45
+ description: 'Select component with search option',
46
+ },
47
+ searchLabel: {
48
+ description: 'Label for the search ',
49
+ },
44
50
  },
45
51
  }
46
52
 
@@ -65,6 +71,7 @@ export const Primary: Story = {
65
71
  accessibilityLabel: 'Select your favourite videogames options',
66
72
  selectedLabel: 'videogames selected',
67
73
  placeholder: 'Select your favourite videogames...',
74
+ isSearchable: false,
68
75
  options: [
69
76
  { id: '1', label: 'The Legend of Zelda: Ocarina of Time' },
70
77
  { id: '2', label: 'Spyro the Dragon' },
@@ -87,6 +94,7 @@ export const PrimaryWithSelectedOptions: Story = {
87
94
  label: 'Videogames',
88
95
  selectedLabel: 'videogames selected',
89
96
  placeholder: 'Select your favourite videogames...',
97
+ isSearchable: false,
90
98
  options: [
91
99
  { id: '1', label: 'The Legend of Zelda: Ocarina of Time' },
92
100
  { id: '2', label: 'Spyro the Dragon' },
@@ -111,6 +119,7 @@ export const PrimaryWithErrors: Story = {
111
119
  accessibilityLabel: 'Select your favourite videogames options',
112
120
  selectedLabel: 'videogames selected',
113
121
  placeholder: 'Select your favourite videogames...',
122
+ isSearchable: false,
114
123
  options: [
115
124
  { id: '1', label: 'The Legend of Zelda: Ocarina of Time' },
116
125
  { id: '2', label: 'Spyro the Dragon' },
@@ -123,3 +132,27 @@ export const PrimaryWithErrors: Story = {
123
132
  },
124
133
  parameters: figmaPrimaryDesign,
125
134
  }
135
+ export const PrimaryWithSearch: Story = {
136
+ args: {
137
+ variant: 'primary',
138
+ disabled: false,
139
+ hideLabel: false,
140
+ helpText: 'This text can help you',
141
+ name: 'videogames',
142
+ label: 'Videogames',
143
+ accessibilityLabel: 'Select your favourite videogames options',
144
+ selectedLabel: 'videogames selected',
145
+ placeholder: 'Select your favourite videogames...',
146
+ options: [
147
+ { id: '1', label: 'The Legend of Zelda: Ocarina of Time' },
148
+ { id: '2', label: 'Spyro the Dragon' },
149
+ { id: '3', label: 'Halo' },
150
+ { id: '4', label: 'Tetris' },
151
+ { id: '5', label: 'Super Mario Bros' },
152
+ { id: '6', label: 'Red Dead Redemption' },
153
+ ],
154
+ isSearchable: true,
155
+ searchLabel: 'Search',
156
+ },
157
+ parameters: figmaPrimaryDesign,
158
+ }
@@ -41,6 +41,12 @@ const meta = {
41
41
  description:
42
42
  'Optional array of errors. If passed, the errors are listed and invalid style is applied.',
43
43
  },
44
+ isSearchable: {
45
+ description: 'Select component with search option',
46
+ },
47
+ searchLabel: {
48
+ description: 'Label for the search ',
49
+ },
44
50
  },
45
51
  }
46
52
 
@@ -63,6 +69,7 @@ export const Primary: Story = {
63
69
  label: 'Videogames',
64
70
  accessibilityLabel: 'Select your favourite gaming system options',
65
71
  hideLabel: false,
72
+ isSearchable: false,
66
73
  placeholder: 'Select your favourite gaming system...',
67
74
  options: [
68
75
  { id: '1', label: 'Nintendo Switch' },
@@ -100,6 +107,7 @@ export const PrimaryWithSelectedOptions: Story = {
100
107
  label: 'Videogames',
101
108
  hideLabel: false,
102
109
  placeholder: 'Select your favourite gaming system...',
110
+ isSearchable: false,
103
111
  options: [
104
112
  { id: '1', label: 'Nintendo Switch' },
105
113
  { id: '2', label: 'PlayStation 5' },
@@ -121,6 +129,7 @@ export const PrimaryWithErrors: Story = {
121
129
  accessibilityLabel: 'Select your favourite gaming system options',
122
130
  hideLabel: false,
123
131
  placeholder: 'Select your favourite gaming system...',
132
+ isSearchable: false,
124
133
  options: [
125
134
  { id: '1', label: 'Nintendo Switch' },
126
135
  { id: '2', label: 'PlayStation 5' },
@@ -132,3 +141,41 @@ export const PrimaryWithErrors: Story = {
132
141
  },
133
142
  parameters: figmaPrimaryDesign,
134
143
  }
144
+
145
+ export const PrimaryWithSearch: Story = {
146
+ args: {
147
+ variant: 'primary',
148
+ disabled: false,
149
+ helpText: 'This text can help you',
150
+ name: 'example',
151
+ label: 'Videogames',
152
+ accessibilityLabel: 'Select your favourite gaming system options',
153
+ hideLabel: false,
154
+ placeholder: 'Select your favourite gaming system...',
155
+ options: [
156
+ { id: '1', label: 'Nintendo Switch' },
157
+ { id: '2', label: 'PlayStation 5' },
158
+ { id: '3', label: 'Xbox Series S/X' },
159
+ { id: '4', label: 'PC' },
160
+ { id: '5', label: 'Mobile' },
161
+ { id: '6', label: 'PlayStation 4' },
162
+ { id: '7', label: 'Xbox One' },
163
+ { id: '8', label: 'PlayStation 3' },
164
+ { id: '9', label: 'Xbox 360' },
165
+ { id: '10', label: 'PlayStation 2' },
166
+ { id: '11', label: 'Xbox' },
167
+ { id: '12', label: 'PlayStation' },
168
+ { id: '13', label: 'Nintendo 64' },
169
+ { id: '14', label: 'Super Nintendo' },
170
+ { id: '15', label: 'Sega Genesis' },
171
+ { id: '16', label: 'Sega Saturn' },
172
+ { id: '17', label: 'Sega Dreamcast' },
173
+ { id: '18', label: 'Atari 2600' },
174
+ ],
175
+ id: 'select-videogames',
176
+ onChange: (optionId) => console.log('onChange optionId:', optionId),
177
+ isSearchable: true,
178
+ searchLabel: 'Search',
179
+ },
180
+ parameters: figmaPrimaryDesign,
181
+ }
@@ -0,0 +1,4 @@
1
+ export function normalizeSearch(value: string): string {
2
+ const pattern = value.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
3
+ return pattern.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase()
4
+ }
@@ -30,6 +30,7 @@ describe('Multiselect', () => {
30
30
  placeholder="Select your favourite videogames..."
31
31
  selectedLabel="videogames selected"
32
32
  variant="primary"
33
+ isSearchable={false}
33
34
  />,
34
35
  )
35
36
 
@@ -70,6 +71,7 @@ describe('Multiselect', () => {
70
71
  defaultValue={['2', '1']}
71
72
  selectedLabel="videogames selected"
72
73
  variant="primary"
74
+ isSearchable={false}
73
75
  />,
74
76
  )
75
77
 
@@ -106,6 +108,7 @@ describe('Multiselect', () => {
106
108
  placeholder="Select your favourite videogames..."
107
109
  selectedLabel="videogames selected"
108
110
  variant="primary"
111
+ isSearchable={false}
109
112
  />,
110
113
  )
111
114
 
@@ -140,6 +143,7 @@ describe('Multiselect', () => {
140
143
  selectedLabel="videogames selected"
141
144
  onChange={mockChange}
142
145
  variant="primary"
146
+ isSearchable={false}
143
147
  />,
144
148
  )
145
149
 
@@ -148,4 +152,45 @@ describe('Multiselect', () => {
148
152
  expect(getByText(placeholder)).toBeInTheDocument()
149
153
  expect(mockChange).toHaveBeenCalledWith([])
150
154
  })
155
+
156
+ it('return filtered options by search', async () => {
157
+ const user = userEvent.setup()
158
+ const placeholder = 'Select your favourite gaming system...'
159
+ const options = [
160
+ {
161
+ id: '1',
162
+ label: 'Nintendo Switch',
163
+ },
164
+ {
165
+ id: '2',
166
+ label: 'PlayStation 5',
167
+ },
168
+ {
169
+ id: '3',
170
+ label: 'Xbox Series S/X',
171
+ },
172
+ ]
173
+ const { queryByText, getByText } = render(
174
+ <Multiselect
175
+ helpText="This text can help you"
176
+ id="select-videogames"
177
+ label="Videogames"
178
+ name="example"
179
+ isSearchable={true}
180
+ options={options}
181
+ placeholder={placeholder}
182
+ variant="primary"
183
+ />,
184
+ )
185
+
186
+ await user.click(screen.getByRole('alert'))
187
+
188
+ const input = screen.getByRole('textbox')
189
+
190
+ await user.type(input, ' PlaySta')
191
+
192
+ expect(getByText('PlayStation 5')).toBeInTheDocument()
193
+ expect(queryByText('Nintendo Switch')).not.toBeInTheDocument()
194
+ expect(queryByText('Xbox Series S/X')).not.toBeInTheDocument()
195
+ })
151
196
  })
@@ -11,6 +11,7 @@ describe('Select', () => {
11
11
  helpText="This text can help you"
12
12
  id="select-videogames"
13
13
  label="Videogames"
14
+ isSearchable={false}
14
15
  name="example"
15
16
  options={[
16
17
  {
@@ -54,6 +55,7 @@ describe('Select', () => {
54
55
  id="select-videogames"
55
56
  label="Videogames"
56
57
  name="example"
58
+ isSearchable={false}
57
59
  options={[
58
60
  {
59
61
  id: '1',
@@ -94,6 +96,7 @@ describe('Select', () => {
94
96
  id="select-videogames"
95
97
  label="Videogames"
96
98
  name="example"
99
+ isSearchable={false}
97
100
  onChange={() => {}}
98
101
  options={[
99
102
  {
@@ -128,6 +131,7 @@ describe('Select', () => {
128
131
  id="select-videogames"
129
132
  label="Videogames"
130
133
  name="example"
134
+ isSearchable={false}
131
135
  options={[
132
136
  {
133
137
  id: '1',
@@ -153,4 +157,45 @@ describe('Select', () => {
153
157
  expect(getByText(placeholder)).toBeInTheDocument()
154
158
  expect(mockChange).toHaveBeenCalledWith('')
155
159
  })
160
+
161
+ it('return filtered options by search', async () => {
162
+ const user = userEvent.setup()
163
+ const placeholder = 'Select your favourite gaming system...'
164
+ const options = [
165
+ {
166
+ id: '1',
167
+ label: 'Nintendo Switch',
168
+ },
169
+ {
170
+ id: '2',
171
+ label: 'PlayStation 5',
172
+ },
173
+ {
174
+ id: '3',
175
+ label: 'Xbox Series S/X',
176
+ },
177
+ ]
178
+ const { queryByText, getByText } = render(
179
+ <Select
180
+ helpText="This text can help you"
181
+ id="select-videogames"
182
+ label="Videogames"
183
+ name="example"
184
+ isSearchable={true}
185
+ options={options}
186
+ placeholder={placeholder}
187
+ variant="primary"
188
+ />,
189
+ )
190
+
191
+ await user.click(screen.getByRole('alert'))
192
+
193
+ const input = screen.getByRole('textbox')
194
+
195
+ await user.type(input, ' PlaySta')
196
+
197
+ expect(getByText('PlayStation 5')).toBeInTheDocument()
198
+ expect(queryByText('Nintendo Switch')).not.toBeInTheDocument()
199
+ expect(queryByText('Xbox Series S/X')).not.toBeInTheDocument()
200
+ })
156
201
  })
@@ -0,0 +1,7 @@
1
+ import { normalizeSearch } from '../../src/utils/normalizeSearch'
2
+
3
+ describe('normalizeSearch', () => {
4
+ it('returns a normalize string without unusual characters, capital letters or accents', () => {
5
+ expect(normalizeSearch('Piñón')).toBe('pinon')
6
+ })
7
+ })
File without changes
File without changes