agroptima-design-system 1.2.31 → 1.2.32-beta.1
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 +1 -1
- package/src/atoms/Input.scss +2 -2
- package/src/atoms/Input.tsx +1 -1
- package/src/atoms/InputWithSelect/InputWithSelect.scss +73 -0
- package/src/atoms/InputWithSelect/InputWithSelect.tsx +112 -0
- package/src/atoms/InputWithSelect/index.ts +4 -0
- package/src/stories/Changelog.mdx +5 -0
- package/src/stories/InputWithSelect.stories.tsx +90 -0
- package/tests/InputWithSelect.spec.tsx +70 -0
- package/tests/library.ts +1 -0
package/package.json
CHANGED
package/src/atoms/Input.scss
CHANGED
package/src/atoms/Input.tsx
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
@use '../../settings/typography/form' as typography;
|
|
2
|
+
@use '../../settings/config';
|
|
3
|
+
@use '../../settings/depth';
|
|
4
|
+
@use '../../settings/mixins';
|
|
5
|
+
|
|
6
|
+
.select-suffix {
|
|
7
|
+
position: relative;
|
|
8
|
+
display: flex;
|
|
9
|
+
align-self: stretch;
|
|
10
|
+
margin: (config.$space-2x * -1) (config.$space-3x * -1) (config.$space-2x * -1)
|
|
11
|
+
0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.select-suffix-trigger {
|
|
15
|
+
@include typography.select-text;
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: config.$space-1x;
|
|
19
|
+
height: 100%;
|
|
20
|
+
padding: config.$space-2x config.$space-3x;
|
|
21
|
+
border: none;
|
|
22
|
+
border-left: 1px solid var(--neutral-color-600);
|
|
23
|
+
border-top-right-radius: config.$corner-radius-m;
|
|
24
|
+
border-bottom-right-radius: config.$corner-radius-m;
|
|
25
|
+
background: var(--neutral-color-50);
|
|
26
|
+
color: var(--neutral-color-1000);
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
-webkit-appearance: none;
|
|
29
|
+
-moz-appearance: none;
|
|
30
|
+
appearance: none;
|
|
31
|
+
@include mixins.icon-color(var(--primary-color-600));
|
|
32
|
+
|
|
33
|
+
&:focus-visible {
|
|
34
|
+
outline: none;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&:disabled {
|
|
38
|
+
border-left: 1px solid var(--neutral-color-400);
|
|
39
|
+
color: var(--neutral-color-400);
|
|
40
|
+
cursor: default;
|
|
41
|
+
@include mixins.icon-color(var(--neutral-color-400));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.select-suffix-options {
|
|
46
|
+
position: absolute;
|
|
47
|
+
top: calc(100% + #{config.$space-2x});
|
|
48
|
+
right: 0;
|
|
49
|
+
min-width: 100%;
|
|
50
|
+
margin: 0;
|
|
51
|
+
padding: config.$space-1x 0;
|
|
52
|
+
list-style: none;
|
|
53
|
+
text-align: left;
|
|
54
|
+
border-radius: config.$corner-radius-xxs;
|
|
55
|
+
background: var(--neutral-white);
|
|
56
|
+
z-index: depth.$z-dropdown-options;
|
|
57
|
+
box-shadow:
|
|
58
|
+
0 9px 28px 8px rgba(0, 0, 0, 0.05),
|
|
59
|
+
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
|
60
|
+
0 3px 6px -4px rgba(0, 0, 0, 0.12);
|
|
61
|
+
|
|
62
|
+
.option {
|
|
63
|
+
@include typography.select-option-text;
|
|
64
|
+
padding: config.$space-2x config.$space-3x;
|
|
65
|
+
cursor: default;
|
|
66
|
+
list-style-type: none;
|
|
67
|
+
text-wrap: nowrap;
|
|
68
|
+
|
|
69
|
+
&:hover {
|
|
70
|
+
background-color: var(--primary-color-50);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import './InputWithSelect.scss'
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { useOpen } from '../../hooks/useOpen'
|
|
4
|
+
import { useOutsideClick } from '../../hooks/useOutsideClick'
|
|
5
|
+
import { classNames } from '../../utils/classNames'
|
|
6
|
+
import { Icon } from '../Icon'
|
|
7
|
+
import type { InputProps } from '../Input'
|
|
8
|
+
import { Input } from '../Input'
|
|
9
|
+
import type { Option } from '../Select'
|
|
10
|
+
import { SelectItem } from '../Select/SelectItem'
|
|
11
|
+
|
|
12
|
+
const ESCAPE_KEY = 'Escape'
|
|
13
|
+
const EMPTY_OPTION: Option = { id: '', label: '' }
|
|
14
|
+
|
|
15
|
+
export interface InputWithSelectProps extends Omit<
|
|
16
|
+
InputProps,
|
|
17
|
+
'suffix' | 'rightIcon'
|
|
18
|
+
> {
|
|
19
|
+
options: Option[]
|
|
20
|
+
selectAccessibilityLabel: string
|
|
21
|
+
selectFieldName?: string
|
|
22
|
+
defaultSelectedOption?: string
|
|
23
|
+
onSelectChange?: (value: string) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function InputWithSelect({
|
|
27
|
+
options,
|
|
28
|
+
selectAccessibilityLabel,
|
|
29
|
+
selectFieldName,
|
|
30
|
+
defaultSelectedOption,
|
|
31
|
+
onSelectChange = () => {},
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: InputWithSelectProps): React.JSX.Element {
|
|
35
|
+
const { isOpen, close, toggle } = useOpen()
|
|
36
|
+
const defaultOption =
|
|
37
|
+
options.find((option) => option.id === defaultSelectedOption) ||
|
|
38
|
+
options[0] ||
|
|
39
|
+
EMPTY_OPTION
|
|
40
|
+
const [selectedOption, setSelectedOption] = useState<Option>(defaultOption)
|
|
41
|
+
const containerRef = useRef(null)
|
|
42
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
43
|
+
const optionsId = `${props.id || props.name}-select-options`
|
|
44
|
+
|
|
45
|
+
useOutsideClick(containerRef, close)
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isOpen) return
|
|
49
|
+
|
|
50
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
51
|
+
if (event.key !== ESCAPE_KEY) return
|
|
52
|
+
close()
|
|
53
|
+
triggerRef.current?.focus()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
57
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
58
|
+
}, [isOpen, close])
|
|
59
|
+
|
|
60
|
+
function handleSelectOption(option: Option) {
|
|
61
|
+
setSelectedOption(option)
|
|
62
|
+
onSelectChange(option.id)
|
|
63
|
+
close()
|
|
64
|
+
triggerRef.current?.focus()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Input
|
|
69
|
+
className={classNames('input-with-select', className)}
|
|
70
|
+
suffix={
|
|
71
|
+
<span className="select-suffix" ref={containerRef}>
|
|
72
|
+
<button
|
|
73
|
+
ref={triggerRef}
|
|
74
|
+
type="button"
|
|
75
|
+
role="combobox"
|
|
76
|
+
className="select-suffix-trigger"
|
|
77
|
+
aria-label={`${selectAccessibilityLabel}: ${selectedOption.label}`}
|
|
78
|
+
aria-haspopup="listbox"
|
|
79
|
+
aria-expanded={isOpen}
|
|
80
|
+
aria-controls={optionsId}
|
|
81
|
+
disabled={props.disabled}
|
|
82
|
+
onClick={toggle}
|
|
83
|
+
>
|
|
84
|
+
<span>{selectedOption.label}</span>
|
|
85
|
+
<Icon size="3" name={isOpen ? 'AngleUp' : 'AngleDown'} decorative />
|
|
86
|
+
</button>
|
|
87
|
+
{selectFieldName && (
|
|
88
|
+
<input
|
|
89
|
+
type="hidden"
|
|
90
|
+
name={selectFieldName}
|
|
91
|
+
value={selectedOption.id}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
{isOpen && (
|
|
95
|
+
<ul id={optionsId} className="select-suffix-options" role="listbox">
|
|
96
|
+
{options.map((option) => (
|
|
97
|
+
<SelectItem
|
|
98
|
+
key={option.id}
|
|
99
|
+
label={option.label}
|
|
100
|
+
isSelected={option.id === selectedOption.id}
|
|
101
|
+
onSelectOption={() => handleSelectOption(option)}
|
|
102
|
+
onClose={close}
|
|
103
|
+
/>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
)}
|
|
107
|
+
</span>
|
|
108
|
+
}
|
|
109
|
+
{...props}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { StoryObj } from '@storybook/nextjs'
|
|
2
|
+
import { InputWithSelect } from '../atoms/InputWithSelect'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Design System/Atoms/InputWithSelect',
|
|
6
|
+
component: InputWithSelect,
|
|
7
|
+
parameters: {
|
|
8
|
+
docs: {
|
|
9
|
+
description: {
|
|
10
|
+
component:
|
|
11
|
+
'<h2>Usage guidelines</h2>' +
|
|
12
|
+
'<p>The InputWithSelect component is used when the value the user types needs a unit or symbol attached to it, such as a discount percentage or a measurement. It combines an input field with a compact select fused into the same field, placed as a suffix.</p>' +
|
|
13
|
+
'<ul>' +
|
|
14
|
+
' <li>Use when the unit is closely tied to the value and only a short, fixed list of symbols applies (e.g. %, kg, m²)</li>' +
|
|
15
|
+
' <li>Keep the list of options short, these are not meant to be searched</li>' +
|
|
16
|
+
' <li>Always include a visible label and a select accessibility label</li>' +
|
|
17
|
+
' <li>Support help and error text as in a standard input</li>' +
|
|
18
|
+
'</ul>',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
tags: ['autodocs', 'Data entry components'],
|
|
23
|
+
argTypes: {
|
|
24
|
+
label: {
|
|
25
|
+
description: 'Label for the input',
|
|
26
|
+
},
|
|
27
|
+
helpText: {
|
|
28
|
+
description: 'Optional help text',
|
|
29
|
+
},
|
|
30
|
+
name: {
|
|
31
|
+
description: 'Set name property for the input value',
|
|
32
|
+
},
|
|
33
|
+
selectFieldName: {
|
|
34
|
+
description:
|
|
35
|
+
'Set name property for the hidden input holding the selected option, if it needs to be submitted in a form',
|
|
36
|
+
},
|
|
37
|
+
options: {
|
|
38
|
+
description: 'Array of symbols/units to be displayed on the select',
|
|
39
|
+
},
|
|
40
|
+
defaultSelectedOption: {
|
|
41
|
+
description: 'Option id to be selected by default',
|
|
42
|
+
},
|
|
43
|
+
onSelectChange: {
|
|
44
|
+
description: 'Called with the selected option id when it changes',
|
|
45
|
+
},
|
|
46
|
+
selectAccessibilityLabel: {
|
|
47
|
+
description: 'Describes the select purpose for screen readers',
|
|
48
|
+
},
|
|
49
|
+
errors: {
|
|
50
|
+
description:
|
|
51
|
+
'Optional array of errors. If passed, the errors are listed and invalid style is applied.',
|
|
52
|
+
},
|
|
53
|
+
disabled: {
|
|
54
|
+
description: 'Is the field in disabled state?',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default meta
|
|
60
|
+
type Story = StoryObj<typeof meta>
|
|
61
|
+
|
|
62
|
+
export const Primary: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
label: 'Discount',
|
|
65
|
+
name: 'discount',
|
|
66
|
+
type: 'number',
|
|
67
|
+
defaultValue: 10,
|
|
68
|
+
helpText: 'Applied to the total amount',
|
|
69
|
+
selectAccessibilityLabel: 'Select discount unit',
|
|
70
|
+
options: [
|
|
71
|
+
{ id: '%', label: '%' },
|
|
72
|
+
{ id: 'eur', label: '€' },
|
|
73
|
+
],
|
|
74
|
+
id: 'discount',
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const WithErrors: Story = {
|
|
79
|
+
args: {
|
|
80
|
+
...Primary.args,
|
|
81
|
+
errors: ['Discount must be lower than 100%'],
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const Disabled: Story = {
|
|
86
|
+
args: {
|
|
87
|
+
...Primary.args,
|
|
88
|
+
disabled: true,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { InputWithSelect } from '../src/atoms/InputWithSelect'
|
|
5
|
+
|
|
6
|
+
const options = [
|
|
7
|
+
{ id: '%', label: '%' },
|
|
8
|
+
{ id: 'kg', label: 'kg' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
describe('InputWithSelect', () => {
|
|
12
|
+
it('renders the input and the select suffix with its default option', () => {
|
|
13
|
+
render(
|
|
14
|
+
<InputWithSelect
|
|
15
|
+
label="Discount"
|
|
16
|
+
name="discount"
|
|
17
|
+
options={options}
|
|
18
|
+
selectAccessibilityLabel="Select unit"
|
|
19
|
+
type="number"
|
|
20
|
+
/>,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
|
24
|
+
expect(
|
|
25
|
+
screen.getByRole('combobox', { name: 'Select unit: %' }),
|
|
26
|
+
).toHaveTextContent('%')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('opens the options list and updates the selected option on click', async () => {
|
|
30
|
+
const user = userEvent.setup()
|
|
31
|
+
const handleSelectChange = jest.fn()
|
|
32
|
+
render(
|
|
33
|
+
<InputWithSelect
|
|
34
|
+
label="Discount"
|
|
35
|
+
name="discount"
|
|
36
|
+
onSelectChange={handleSelectChange}
|
|
37
|
+
options={options}
|
|
38
|
+
selectAccessibilityLabel="Select unit"
|
|
39
|
+
type="number"
|
|
40
|
+
/>,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
await user.click(screen.getByRole('combobox', { name: 'Select unit: %' }))
|
|
44
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
45
|
+
|
|
46
|
+
await user.click(screen.getByRole('option', { name: 'kg' }))
|
|
47
|
+
|
|
48
|
+
expect(handleSelectChange).toHaveBeenCalledWith('kg')
|
|
49
|
+
expect(
|
|
50
|
+
screen.getByRole('combobox', { name: 'Select unit: kg' }),
|
|
51
|
+
).toHaveTextContent('kg')
|
|
52
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders an empty selection without crashing when options is empty', () => {
|
|
56
|
+
render(
|
|
57
|
+
<InputWithSelect
|
|
58
|
+
label="Discount"
|
|
59
|
+
name="discount"
|
|
60
|
+
options={[]}
|
|
61
|
+
selectAccessibilityLabel="Select unit"
|
|
62
|
+
type="number"
|
|
63
|
+
/>,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(
|
|
67
|
+
screen.getByRole('combobox', { name: 'Select unit:' }),
|
|
68
|
+
).toHaveTextContent('')
|
|
69
|
+
})
|
|
70
|
+
})
|
package/tests/library.ts
CHANGED
|
@@ -23,6 +23,7 @@ export * as IconButton from '../src/stories/IconButton.stories'
|
|
|
23
23
|
export * as InfiniteSelect from '../src/stories/InfiniteSelect.stories'
|
|
24
24
|
export * as Input from '../src/stories/Input.stories'
|
|
25
25
|
export * as InputWithButton from '../src/stories/InputWithButton.stories'
|
|
26
|
+
export * as InputWithSelect from '../src/stories/InputWithSelect.stories'
|
|
26
27
|
export * as Label from '../src/stories/Label.stories'
|
|
27
28
|
export * as Menu from '../src/stories/Menu.stories'
|
|
28
29
|
export * as Modal from '../src/stories/Modal.stories'
|