@talixo-ds/options-input 0.0.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.
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import Typography from '@mui/material/Typography';
3
+
4
+ export type MinMaxValueLabelProps = {
5
+ min?: number;
6
+ max?: number;
7
+ color?: string;
8
+ };
9
+
10
+ export const MinMaxValueLabel = ({
11
+ min,
12
+ max,
13
+ color,
14
+ }: MinMaxValueLabelProps) => (
15
+ <Typography variant="caption" color={color} sx={{ my: 0 }}>
16
+ {!Number.isNaN(Number(min)) && `min: ${min}`}
17
+ {!Number.isNaN(Number(max)) && !Number.isNaN(Number(min)) && ', '}
18
+ {!Number.isNaN(Number(max)) && `max: ${max}`}
19
+ </Typography>
20
+ );
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import Tooltip from '@mui/material/Tooltip';
3
+ import Box from '@mui/material/Box';
4
+ import Typography from '@mui/material/Typography';
5
+ import * as MuiIcons from '@mui/icons-material';
6
+ import * as DesignSystemIcons from '@talixo-ds/component.icons';
7
+ import { MinMaxValueLabel } from './min-max-value-label';
8
+ import { OptionsInputOption } from '../types';
9
+
10
+ export type OptionsInputContentItemProps = {
11
+ item: OptionsInputOption;
12
+ disabled?: boolean;
13
+ displayMinMax?: boolean;
14
+ };
15
+
16
+ const OptionsInputContentItem = ({
17
+ item: { quantity, details, label, max, min, icon },
18
+ displayMinMax = false,
19
+ disabled = false,
20
+ }: OptionsInputContentItemProps) => {
21
+ const Icon = DesignSystemIcons[icon] || MuiIcons[icon];
22
+ const itemsColor = quantity === 0 || disabled ? '#a4a5b2' : '#000000';
23
+
24
+ return (
25
+ <Box
26
+ display="flex"
27
+ alignItems="center"
28
+ gap={0.5}
29
+ color={itemsColor}
30
+ data-testid="option-item"
31
+ >
32
+ {label ? (
33
+ <Tooltip
34
+ title={
35
+ <Box display="flex" flexDirection="column">
36
+ <Typography variant="caption" fontWeight={600} sx={{ my: 0 }}>
37
+ {label}
38
+ </Typography>
39
+ {details && (
40
+ <Typography variant="caption" sx={{ my: 0 }}>
41
+ {details}
42
+ </Typography>
43
+ )}
44
+ {displayMinMax && <MinMaxValueLabel min={min} max={max} />}
45
+ </Box>
46
+ }
47
+ placement="top"
48
+ arrow
49
+ >
50
+ <span>
51
+ <Icon fontSize="medium" color={itemsColor} />
52
+ </span>
53
+ </Tooltip>
54
+ ) : (
55
+ <Icon fontSize="medium" color={itemsColor} />
56
+ )}
57
+ <Typography variant="h6" color={itemsColor}>
58
+ {quantity}
59
+ </Typography>
60
+ </Box>
61
+ );
62
+ };
63
+
64
+ export default OptionsInputContentItem;
@@ -0,0 +1,148 @@
1
+ import React, { useState } from 'react';
2
+ import classNames from 'classnames';
3
+ import Box from '@mui/material/Box';
4
+ import Typography from '@mui/material/Typography';
5
+ import ButtonGroup from '@mui/material/ButtonGroup';
6
+ import Divider from '@mui/material/Divider';
7
+ import TextField from '@mui/material/TextField';
8
+ import ListItem from '@mui/material/ListItem';
9
+ import Button from '@mui/material/Button';
10
+ import AddIcon from '@mui/icons-material/Add';
11
+ import RemoveIcon from '@mui/icons-material/Remove';
12
+ import * as MuiIcons from '@mui/icons-material';
13
+ import * as DesignSystemIcons from '@talixo-ds/component.icons';
14
+ import { MinMaxValueLabel } from './min-max-value-label';
15
+ import { OptionsInputOption } from '../types';
16
+
17
+ export type OptionsInputDropdownItemProps = {
18
+ item: OptionsInputOption;
19
+ onBlur: () => void;
20
+ onChange: (id: string, value: number | string) => void;
21
+ index: number;
22
+ displayMinMax?: boolean;
23
+ };
24
+
25
+ const OptionsInputDropdownItem = ({
26
+ item: { id, quantity = 0, label, max, min, icon, details, inputQuantity },
27
+ onChange,
28
+ onBlur,
29
+ index,
30
+ displayMinMax,
31
+ }: OptionsInputDropdownItemProps) => {
32
+ const [shouldDisplayFullDetails, setShouldDisplayFullDetails] =
33
+ useState<boolean>(false);
34
+ const Icon = DesignSystemIcons[icon] || MuiIcons[icon];
35
+
36
+ return (
37
+ <>
38
+ {!!index && (
39
+ <Divider sx={{ color: (theme) => theme.palette.primary.main }} />
40
+ )}
41
+ <ListItem
42
+ sx={{
43
+ display: 'flex',
44
+ justifyContent: 'space-between',
45
+ }}
46
+ className={classNames('options-input__dropdown-item', {
47
+ 'options-input__dropdown-item--empty': !quantity,
48
+ })}
49
+ >
50
+ <Box display="flex" alignItems="center">
51
+ <Icon fontSize="small" color="black" />
52
+ <TextField
53
+ onChange={({ target }) => onChange(id, target.value)}
54
+ onBlur={onBlur}
55
+ value={inputQuantity}
56
+ variant="standard"
57
+ inputProps={{
58
+ inputMode: 'numeric',
59
+ pattern: '-?[0-9]*',
60
+ style: {
61
+ textAlign: 'center',
62
+ },
63
+ 'data-testid': 'dropdown-item-input',
64
+ }}
65
+ // eslint-disable-next-line react/jsx-no-duplicate-props
66
+ InputProps={{ disableUnderline: true }}
67
+ className="options-input__dropdown-item-input"
68
+ />
69
+ <Box
70
+ display="flex"
71
+ flexDirection="column"
72
+ justifyContent="center"
73
+ paddingRight={2}
74
+ paddingLeft={1}
75
+ minWidth="5rem"
76
+ >
77
+ <Typography
78
+ variant="caption"
79
+ fontWeight={600}
80
+ fontSize={13}
81
+ sx={{ my: 0 }}
82
+ color="black"
83
+ >
84
+ {label || id}
85
+ </Typography>
86
+ {details && (
87
+ <Box
88
+ position="relative"
89
+ height="1rem"
90
+ data-testid="option-details-container"
91
+ onMouseEnter={() => setShouldDisplayFullDetails(true)}
92
+ onMouseLeave={() => setShouldDisplayFullDetails(false)}
93
+ >
94
+ <Typography
95
+ variant="caption"
96
+ color="gray"
97
+ sx={{
98
+ my: 0,
99
+ zIndex: 10000,
100
+ position: 'fixed',
101
+ ...(shouldDisplayFullDetails && {
102
+ backgroundColor: quantity ? '#ffffff' : '#eeeeee',
103
+ border: 'thin solid #d3d3d3',
104
+ }),
105
+ }}
106
+ data-testid="option-details"
107
+ >
108
+ {details?.length <= 15 || shouldDisplayFullDetails
109
+ ? details
110
+ : `${details?.slice(0, 15)}...`}
111
+ </Typography>
112
+ </Box>
113
+ )}
114
+ {displayMinMax && (
115
+ <MinMaxValueLabel min={min} max={max} color="gray" />
116
+ )}
117
+ </Box>
118
+ </Box>
119
+ <ButtonGroup
120
+ variant="outlined"
121
+ size="small"
122
+ className="options-input__dropdown-item-buttons"
123
+ >
124
+ <Button
125
+ onClick={() => onChange(id, quantity + 1)}
126
+ disabled={quantity === max}
127
+ className="options-input__dropdown-item-button"
128
+ role="button"
129
+ color="primary"
130
+ >
131
+ <AddIcon sx={{ color: 'primary' }} />
132
+ </Button>
133
+ <Button
134
+ onClick={() => onChange(id, quantity - 1)}
135
+ disabled={quantity === min}
136
+ className="options-input__dropdown-item-button"
137
+ role="button"
138
+ color="primary"
139
+ >
140
+ <RemoveIcon sx={{ color: 'primary' }} />
141
+ </Button>
142
+ </ButtonGroup>
143
+ </ListItem>
144
+ </>
145
+ );
146
+ };
147
+
148
+ export default OptionsInputDropdownItem;
@@ -0,0 +1,17 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`MinMaxValueLabel should match snapshot 1`] = `
4
+ <ForwardRef(Typography)
5
+ color="black"
6
+ sx={
7
+ {
8
+ "my": 0,
9
+ }
10
+ }
11
+ variant="caption"
12
+ >
13
+ min: 0
14
+ ,
15
+ max: 100
16
+ </ForwardRef(Typography)>
17
+ `;
@@ -0,0 +1,138 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`OptionsInputDropdownItem should match snapshot 1`] = `
4
+ <ForwardRef(Box)
5
+ alignItems="center"
6
+ color="#a4a5b2"
7
+ data-testid="option-item"
8
+ display="flex"
9
+ gap={0.5}
10
+ >
11
+ <ForwardRef(Tooltip)
12
+ arrow={true}
13
+ placement="top"
14
+ title={
15
+ <ForwardRef(Box)
16
+ display="flex"
17
+ flexDirection="column"
18
+ >
19
+ <ForwardRef(Typography)
20
+ fontWeight={600}
21
+ sx={
22
+ {
23
+ "my": 0,
24
+ }
25
+ }
26
+ variant="caption"
27
+ >
28
+ luggage
29
+ </ForwardRef(Typography)>
30
+ <ForwardRef(Typography)
31
+ sx={
32
+ {
33
+ "my": 0,
34
+ }
35
+ }
36
+ variant="caption"
37
+ >
38
+ This is your luggage
39
+ </ForwardRef(Typography)>
40
+ <MinMaxValueLabel
41
+ max={10}
42
+ min={-10}
43
+ />
44
+ </ForwardRef(Box)>
45
+ }
46
+ >
47
+ <span>
48
+ <div
49
+ color="#a4a5b2"
50
+ fontSize="medium"
51
+ />
52
+ </span>
53
+ </ForwardRef(Tooltip)>
54
+ <ForwardRef(Typography)
55
+ color="#a4a5b2"
56
+ variant="h6"
57
+ >
58
+ 0
59
+ </ForwardRef(Typography)>
60
+ </ForwardRef(Box)>
61
+ `;
62
+
63
+ exports[`OptionsInputDropdownItem should not render tooltip when there is no label 1`] = `
64
+ <ForwardRef(Box)
65
+ alignItems="center"
66
+ color="#a4a5b2"
67
+ data-testid="option-item"
68
+ display="flex"
69
+ gap={0.5}
70
+ >
71
+ <div
72
+ color="#a4a5b2"
73
+ fontSize="medium"
74
+ />
75
+ <ForwardRef(Typography)
76
+ color="#a4a5b2"
77
+ variant="h6"
78
+ >
79
+ 0
80
+ </ForwardRef(Typography)>
81
+ </ForwardRef(Box)>
82
+ `;
83
+
84
+ exports[`OptionsInputDropdownItem should render proper alternative styling 1`] = `
85
+ <ForwardRef(Box)
86
+ alignItems="center"
87
+ color="#000000"
88
+ data-testid="option-item"
89
+ display="flex"
90
+ gap={0.5}
91
+ >
92
+ <ForwardRef(Tooltip)
93
+ arrow={true}
94
+ placement="top"
95
+ title={
96
+ <ForwardRef(Box)
97
+ display="flex"
98
+ flexDirection="column"
99
+ >
100
+ <ForwardRef(Typography)
101
+ fontWeight={600}
102
+ sx={
103
+ {
104
+ "my": 0,
105
+ }
106
+ }
107
+ variant="caption"
108
+ >
109
+ luggage
110
+ </ForwardRef(Typography)>
111
+ <ForwardRef(Typography)
112
+ sx={
113
+ {
114
+ "my": 0,
115
+ }
116
+ }
117
+ variant="caption"
118
+ >
119
+ This is your luggage
120
+ </ForwardRef(Typography)>
121
+ </ForwardRef(Box)>
122
+ }
123
+ >
124
+ <span>
125
+ <div
126
+ color="#000000"
127
+ fontSize="medium"
128
+ />
129
+ </span>
130
+ </ForwardRef(Tooltip)>
131
+ <ForwardRef(Typography)
132
+ color="#000000"
133
+ variant="h6"
134
+ >
135
+ 1
136
+ </ForwardRef(Typography)>
137
+ </ForwardRef(Box)>
138
+ `;
@@ -0,0 +1,134 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`OptionsInputDropdownItem should match snapshot 1`] = `
4
+ <React.Fragment>
5
+ <ForwardRef(Divider)
6
+ sx={
7
+ {
8
+ "color": [Function],
9
+ }
10
+ }
11
+ />
12
+ <ForwardRef(ListItem)
13
+ className="options-input__dropdown-item options-input__dropdown-item--empty"
14
+ sx={
15
+ {
16
+ "display": "flex",
17
+ "justifyContent": "space-between",
18
+ }
19
+ }
20
+ >
21
+ <ForwardRef(Box)
22
+ alignItems="center"
23
+ display="flex"
24
+ >
25
+ <div
26
+ color="black"
27
+ fontSize="small"
28
+ />
29
+ <ForwardRef(TextField)
30
+ InputProps={
31
+ {
32
+ "disableUnderline": true,
33
+ }
34
+ }
35
+ className="options-input__dropdown-item-input"
36
+ inputProps={
37
+ {
38
+ "data-testid": "dropdown-item-input",
39
+ "inputMode": "numeric",
40
+ "pattern": "-?[0-9]*",
41
+ "style": {
42
+ "textAlign": "center",
43
+ },
44
+ }
45
+ }
46
+ onBlur={[MockFunction]}
47
+ onChange={[Function]}
48
+ value={0}
49
+ variant="standard"
50
+ />
51
+ <ForwardRef(Box)
52
+ display="flex"
53
+ flexDirection="column"
54
+ justifyContent="center"
55
+ minWidth="5rem"
56
+ paddingLeft={1}
57
+ paddingRight={2}
58
+ >
59
+ <ForwardRef(Typography)
60
+ color="black"
61
+ fontSize={13}
62
+ fontWeight={600}
63
+ sx={
64
+ {
65
+ "my": 0,
66
+ }
67
+ }
68
+ variant="caption"
69
+ >
70
+ luggage-id
71
+ </ForwardRef(Typography)>
72
+ <ForwardRef(Box)
73
+ data-testid="option-details-container"
74
+ height="1rem"
75
+ onMouseEnter={[Function]}
76
+ onMouseLeave={[Function]}
77
+ position="relative"
78
+ >
79
+ <ForwardRef(Typography)
80
+ color="gray"
81
+ data-testid="option-details"
82
+ sx={
83
+ {
84
+ "my": 0,
85
+ "position": "fixed",
86
+ "zIndex": 10000,
87
+ }
88
+ }
89
+ variant="caption"
90
+ >
91
+ This is your lu...
92
+ </ForwardRef(Typography)>
93
+ </ForwardRef(Box)>
94
+ </ForwardRef(Box)>
95
+ </ForwardRef(Box)>
96
+ <ForwardRef(ButtonGroup)
97
+ className="options-input__dropdown-item-buttons"
98
+ size="small"
99
+ variant="outlined"
100
+ >
101
+ <ForwardRef(Button)
102
+ className="options-input__dropdown-item-button"
103
+ color="primary"
104
+ disabled={false}
105
+ onClick={[Function]}
106
+ role="button"
107
+ >
108
+ <Memo
109
+ sx={
110
+ {
111
+ "color": "primary",
112
+ }
113
+ }
114
+ />
115
+ </ForwardRef(Button)>
116
+ <ForwardRef(Button)
117
+ className="options-input__dropdown-item-button"
118
+ color="primary"
119
+ disabled={false}
120
+ onClick={[Function]}
121
+ role="button"
122
+ >
123
+ <Memo
124
+ sx={
125
+ {
126
+ "color": "primary",
127
+ }
128
+ }
129
+ />
130
+ </ForwardRef(Button)>
131
+ </ForwardRef(ButtonGroup)>
132
+ </ForwardRef(ListItem)>
133
+ </React.Fragment>
134
+ `;
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import ShallowRenderer from 'react-test-renderer/shallow';
3
+ import '@testing-library/jest-dom';
4
+ import { MinMaxValueLabel } from '../min-max-value-label';
5
+
6
+ const props = {
7
+ min: 0,
8
+ max: 100,
9
+ color: 'black',
10
+ };
11
+
12
+ describe('MinMaxValueLabel', () => {
13
+ let component: JSX.Element;
14
+ const renderer = ShallowRenderer.createRenderer();
15
+
16
+ beforeEach(() => {
17
+ renderer.render(<MinMaxValueLabel {...props} />);
18
+ component = renderer.getRenderOutput();
19
+ });
20
+
21
+ it('should match snapshot', () => {
22
+ expect(component).toMatchSnapshot();
23
+ });
24
+ });
@@ -0,0 +1,56 @@
1
+ import React from 'react';
2
+ import ShallowRenderer from 'react-test-renderer/shallow';
3
+ import '@testing-library/jest-dom';
4
+ import OptionsInputContentItem from '../options-input-content-item';
5
+
6
+ jest.mock('@mui/icons-material', () => ({
7
+ luggage: 'div',
8
+ }));
9
+
10
+ jest.mock('@talixo-ds/component.icons', () => ({
11
+ football: 'div',
12
+ }));
13
+
14
+ const props = {
15
+ item: {
16
+ id: 'luggage',
17
+ icon: 'luggage',
18
+ label: 'luggage',
19
+ details: 'This is your luggage',
20
+ min: -10,
21
+ max: 10,
22
+ quantity: 0,
23
+ inputQuantity: 0,
24
+ },
25
+ displayMinMax: true,
26
+ };
27
+
28
+ describe('OptionsInputDropdownItem', () => {
29
+ let component: JSX.Element;
30
+ const renderer = ShallowRenderer.createRenderer();
31
+
32
+ it('should match snapshot', () => {
33
+ renderer.render(<OptionsInputContentItem {...props} />);
34
+
35
+ component = renderer.getRenderOutput();
36
+ expect(component).toMatchSnapshot();
37
+ });
38
+
39
+ it('should render proper alternative styling', () => {
40
+ renderer.render(
41
+ <OptionsInputContentItem item={{ ...props.item, quantity: 1 }} />
42
+ );
43
+
44
+ component = renderer.getRenderOutput();
45
+ expect(component).toMatchSnapshot();
46
+ });
47
+
48
+ it('should not render tooltip when there is no label', () => {
49
+ renderer.render(
50
+ <OptionsInputContentItem item={{ ...props.item, label: undefined }} />
51
+ );
52
+
53
+ component = renderer.getRenderOutput();
54
+ expect(component).toMatchSnapshot();
55
+ });
56
+ });
@@ -0,0 +1,84 @@
1
+ import React from 'react';
2
+ import ShallowRenderer from 'react-test-renderer/shallow';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+ import OptionsInputDropdownItem from '../options-input-dropdown-item';
6
+
7
+ jest.mock('@mui/icons-material', () => ({
8
+ luggage: 'div',
9
+ }));
10
+
11
+ jest.mock('@talixo-ds/component.icons', () => ({
12
+ football: 'div',
13
+ }));
14
+
15
+ const onChangeMock = jest.fn();
16
+ const onBlurMock = jest.fn();
17
+
18
+ const props = {
19
+ item: {
20
+ id: 'luggage-id',
21
+ icon: 'luggage',
22
+ details:
23
+ 'This is your luggage. Please, keep your attention not to lose it.',
24
+ min: -10,
25
+ max: 10,
26
+ quantity: 0,
27
+ inputQuantity: 0,
28
+ },
29
+ onBlur: onBlurMock,
30
+ onChange: onChangeMock,
31
+ index: 1,
32
+ };
33
+
34
+ describe('OptionsInputDropdownItem', () => {
35
+ let component: JSX.Element;
36
+ const renderer = ShallowRenderer.createRenderer();
37
+
38
+ it('should match snapshot', () => {
39
+ renderer.render(<OptionsInputDropdownItem {...props} />);
40
+ component = renderer.getRenderOutput();
41
+
42
+ expect(component).toMatchSnapshot();
43
+ });
44
+
45
+ describe('events', () => {
46
+ beforeEach(() => render(<OptionsInputDropdownItem {...props} />));
47
+
48
+ it('should call onChange on text field edit', async () => {
49
+ const input = screen.getByTestId('dropdown-item-input');
50
+
51
+ await fireEvent.change(input, { target: { value: '10' } });
52
+ expect(onChangeMock).toHaveBeenCalledWith('luggage-id', '10');
53
+ });
54
+
55
+ it('should call onChange on increment button click', () => {
56
+ const buttons = screen.getAllByRole('button');
57
+
58
+ fireEvent.click(buttons[0]);
59
+ expect(onChangeMock).toHaveBeenCalledWith('luggage-id', 1);
60
+ });
61
+
62
+ it('should call onChange on decrement button click', () => {
63
+ const buttons = screen.getAllByRole('button');
64
+
65
+ fireEvent.click(buttons[1]);
66
+ expect(onChangeMock).toHaveBeenCalledWith('luggage-id', -1);
67
+ });
68
+
69
+ it('should expand details content', () => {
70
+ expect(screen.getByTestId('option-details')).toHaveTextContent(
71
+ props.item.details.slice(0, 15)
72
+ );
73
+
74
+ const optionDetailsContainer = screen.getByTestId(
75
+ 'option-details-container'
76
+ );
77
+
78
+ fireEvent.mouseEnter(optionDetailsContainer);
79
+ expect(screen.getByTestId('option-details')).toHaveTextContent(
80
+ props.item.details
81
+ );
82
+ });
83
+ });
84
+ });
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { OptionsInput } from './options-input';
2
+ export type { OptionsInputProps } from './options-input';
@@ -0,0 +1,273 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import classNames from 'classnames';
3
+ import Box from '@mui/material/Box';
4
+ import List from '@mui/material/List';
5
+ import Popper from '@mui/material/Popper';
6
+ import ClickAwayListener from '@mui/material/ClickAwayListener';
7
+ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
8
+ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
9
+ import * as MuiIcons from '@mui/icons-material';
10
+ import * as DesignSystemIcons from '@talixo-ds/component.icons';
11
+ import type { SxProps } from '@mui/material';
12
+ import { OptionsInputOption, OptionsInputValue } from './types';
13
+ import OptionsInputContentItem from './components/options-input-content-item';
14
+ import OptionsInputDropdownItem from './components/options-input-dropdown-item';
15
+ import './styles.scss';
16
+
17
+ import '@emotion/react';
18
+ import '@emotion/styled';
19
+
20
+ export type OptionsInputProps = {
21
+ /** Array of objects representing options available to choose from */
22
+ options: OptionsInputOption[];
23
+ /** Object with default values of some options */
24
+ defaultValue?: OptionsInputValue;
25
+ /** Array with ids of options which should remain displayed even if value is 0 */
26
+ persistentOptions?: string[];
27
+ /** Boolean to determine if input is disabled */
28
+ disabled?: boolean;
29
+ /** Boolean to determine if input is readOnly */
30
+ readOnly?: boolean;
31
+ /** Boolean to determine if min and max input values should be displayed */
32
+ displayMinMax?: boolean;
33
+ /** Function which handles options input value change */
34
+ onChange?: (OptionsInputValue) => void;
35
+ /** Function which handles options input focus event */
36
+ onFocus?: (OptionsInputValue) => void;
37
+ /** Function which handles options input blur event */
38
+ onBlur?: (OptionsInputValue) => void;
39
+ /** className attached to an input container */
40
+ className?: string;
41
+ /** id attached to an input container */
42
+ id?: string;
43
+ /** data test id attached to an input container */
44
+ 'data-testid'?: string;
45
+ /** Gap between input items */
46
+ itemsGap?: string | number;
47
+ /** Custom styles for container */
48
+ containerSx?: SxProps;
49
+ };
50
+
51
+ export const OptionsInput = ({
52
+ options,
53
+ onChange,
54
+ onFocus,
55
+ onBlur,
56
+ persistentOptions = [],
57
+ defaultValue,
58
+ displayMinMax = false,
59
+ disabled = false,
60
+ readOnly = false,
61
+ id,
62
+ className,
63
+ itemsGap = 1,
64
+ containerSx = [],
65
+ ...rest
66
+ }: OptionsInputProps) => {
67
+ const [currentOptions, setCurrentOptions] = useState<OptionsInputOption[]>(
68
+ []
69
+ );
70
+ const [anchorEl, setAnchorEl] = useState<undefined | HTMLElement>();
71
+ const open = !!anchorEl;
72
+
73
+ useEffect(
74
+ () =>
75
+ setCurrentOptions(
76
+ options.map((option) => {
77
+ const defaultQuantity = defaultValue?.[option.id] ?? 0;
78
+ const minQuantity =
79
+ defaultQuantity < option?.min ? option?.min : defaultQuantity;
80
+ const quantity =
81
+ minQuantity > option?.max ? option?.max : minQuantity;
82
+
83
+ return {
84
+ ...option,
85
+ quantity,
86
+ inputQuantity: quantity,
87
+ };
88
+ })
89
+ ),
90
+ [options, defaultValue]
91
+ );
92
+
93
+ const toggleInput = (event: React.MouseEvent<HTMLElement>) => {
94
+ const { currentTarget } = event;
95
+
96
+ if (!disabled && !readOnly) {
97
+ setTimeout(() => {
98
+ setAnchorEl(anchorEl ? undefined : currentTarget);
99
+ }, 0);
100
+ }
101
+ };
102
+
103
+ const onInputFocus = () => {
104
+ if (onFocus) {
105
+ onFocus(
106
+ currentOptions.reduce(
107
+ (currentValues, currentOption) => ({
108
+ ...currentValues,
109
+ [currentOption.id]: currentOption.quantity,
110
+ }),
111
+ {}
112
+ )
113
+ );
114
+ }
115
+ };
116
+
117
+ const onInputBlur = () => {
118
+ if (onBlur) {
119
+ onBlur(
120
+ currentOptions.reduce(
121
+ (currentValues, currentOption) => ({
122
+ ...currentValues,
123
+ [currentOption.id]: currentOption.quantity,
124
+ }),
125
+ {}
126
+ )
127
+ );
128
+ }
129
+ };
130
+
131
+ const onValueChange = (optionId: string, newValue: string | number) => {
132
+ const newQuantity = Number.isNaN(Number(newValue)) ? 0 : Number(newValue);
133
+
134
+ const newCurrentOptions = currentOptions.map((option) => {
135
+ const maxQuantity =
136
+ newQuantity > (option.max || Infinity) ? option.max : newQuantity;
137
+
138
+ return {
139
+ ...option,
140
+ ...(optionId === option.id && {
141
+ quantity:
142
+ newQuantity < (option.min || -Infinity) ? option.min : maxQuantity,
143
+ inputQuantity: newValue,
144
+ }),
145
+ };
146
+ });
147
+
148
+ if (onChange) {
149
+ onChange(
150
+ newCurrentOptions.reduce(
151
+ (currentValues, currentOption) => ({
152
+ ...currentValues,
153
+ [currentOption.id]: currentOption.quantity,
154
+ }),
155
+ {}
156
+ )
157
+ );
158
+ }
159
+
160
+ setCurrentOptions(newCurrentOptions);
161
+ };
162
+
163
+ const onDropdownItemBlur = (optionId: string) => () =>
164
+ setCurrentOptions(
165
+ currentOptions.map((option) => {
166
+ if (optionId !== option.id) return option;
167
+
168
+ const finalQuantity = Number.isNaN(Number(option.inputQuantity))
169
+ ? 0
170
+ : Number(option.inputQuantity);
171
+ const maxQuantity =
172
+ finalQuantity > (option.max || Infinity) ? option.max : finalQuantity;
173
+
174
+ return {
175
+ ...option,
176
+ inputQuantity:
177
+ finalQuantity < (option.min || -Infinity)
178
+ ? option.min
179
+ : maxQuantity,
180
+ };
181
+ })
182
+ );
183
+
184
+ return (
185
+ <>
186
+ <Box
187
+ id={id}
188
+ onClick={toggleInput}
189
+ onBlur={onInputBlur}
190
+ onFocus={onInputFocus}
191
+ className={classNames('options-input__container', {
192
+ 'options-input__container--open': open,
193
+ 'options-input__container--disabled': disabled,
194
+ 'options-input__container--read-only': readOnly,
195
+ [className]: !!className,
196
+ })}
197
+ sx={[
198
+ { '&:hover': { borderColor: '#d3d3d3' } },
199
+ ...(Array.isArray(containerSx) ? containerSx : [containerSx]),
200
+ open && {
201
+ borderColor: (theme) => theme.palette.primary.main,
202
+ '&:hover': { borderColor: (theme) => theme.palette.primary.main },
203
+ },
204
+ ]}
205
+ data-testid={rest['data-testid'] || 'options-input-container'}
206
+ tabIndex={0}
207
+ >
208
+ <Box display="flex" gap={itemsGap}>
209
+ {currentOptions
210
+ .filter(
211
+ ({ quantity, id: optionId, icon }) =>
212
+ !!(MuiIcons?.[icon] || DesignSystemIcons?.[icon]) &&
213
+ (quantity !== 0 || persistentOptions.includes(optionId))
214
+ )
215
+ .map((option) => (
216
+ <OptionsInputContentItem
217
+ key={option.id}
218
+ item={option}
219
+ disabled={disabled}
220
+ displayMinMax={displayMinMax}
221
+ />
222
+ ))}
223
+ </Box>
224
+ {!readOnly &&
225
+ (open ? (
226
+ <KeyboardArrowUpIcon color="primary" />
227
+ ) : (
228
+ <KeyboardArrowDownIcon
229
+ sx={{
230
+ color: (theme) =>
231
+ disabled
232
+ ? theme.palette.grey[400]
233
+ : theme.palette.action.focus,
234
+ }}
235
+ />
236
+ ))}
237
+ </Box>
238
+ <ClickAwayListener onClickAway={() => open && setAnchorEl(undefined)}>
239
+ <Popper
240
+ open={open}
241
+ placement="bottom-start"
242
+ anchorEl={anchorEl}
243
+ sx={(theme) => ({ zIndex: theme.zIndex.modal })}
244
+ >
245
+ <List
246
+ disablePadding
247
+ data-testid="options-dropdown-list"
248
+ className="options-input__dropdown-items-list"
249
+ sx={{
250
+ bgcolor: 'Background',
251
+ border: (theme) => `thin solid ${theme.palette.primary.main}`,
252
+ }}
253
+ >
254
+ {currentOptions
255
+ .filter(
256
+ ({ icon }) => !!(MuiIcons?.[icon] || DesignSystemIcons?.[icon])
257
+ )
258
+ .map((option, index) => (
259
+ <OptionsInputDropdownItem
260
+ key={option.id}
261
+ item={option}
262
+ onBlur={onDropdownItemBlur(option.id)}
263
+ onChange={onValueChange}
264
+ index={index}
265
+ displayMinMax={displayMinMax}
266
+ />
267
+ ))}
268
+ </List>
269
+ </Popper>
270
+ </ClickAwayListener>
271
+ </>
272
+ );
273
+ };
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@talixo-ds/options-input",
3
+ "version": "0.0.1",
4
+ "main": "src/index.ts",
5
+ "scripts": {
6
+ "storybook": "start-storybook -p 6006"
7
+ },
8
+ "devDependencies": {
9
+ "@storybook/react": "^6.3.0"
10
+ }
11
+ }
package/styles.scss ADDED
@@ -0,0 +1,54 @@
1
+ .options-input {
2
+ &__container {
3
+ border: thin solid transparent;
4
+ border-radius: 0.25rem;
5
+ display: flex;
6
+ justify-content: space-between;
7
+ align-items: center;
8
+ padding: 0.25rem;
9
+ gap: 1rem;
10
+ cursor: pointer;
11
+ width: fit-content;
12
+
13
+ &--open {
14
+ border-bottom-width: 0;
15
+ border-bottom-left-radius: 0;
16
+ border-bottom-right-radius: 0;
17
+ }
18
+
19
+ &--disabled {
20
+ cursor: unset;
21
+ }
22
+
23
+ &--read-only {
24
+ cursor: unset;
25
+
26
+ &:hover {
27
+ border-color: transparent;
28
+ }
29
+ }
30
+ }
31
+
32
+ &__dropdown-item {
33
+ gap: 1rem;
34
+
35
+ &--empty {
36
+ background-color: #eeeeee;
37
+ }
38
+ }
39
+
40
+ &__dropdown-item-input {
41
+ width: 2rem;
42
+ }
43
+
44
+ &__dropdown-item-buttons {
45
+ & > button[role="button"] {
46
+ min-width: 2rem;
47
+ padding: 0;
48
+
49
+ &[disabled] > svg {
50
+ color: #0000001f;
51
+ }
52
+ }
53
+ }
54
+ }
package/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ export interface OptionsInputOption {
2
+ id: string;
3
+ icon: string;
4
+ label?: string;
5
+ details?: string;
6
+ min?: number;
7
+ max?: number;
8
+ quantity?: number;
9
+ inputQuantity?: string | number;
10
+ }
11
+
12
+ export interface OptionsInputValue {
13
+ [key: string]: number;
14
+ }