Skip to content

Commit 3bd6f4c

Browse files
author
damfinkel
authored
Merge pull request #130 from Wolox/feature/selector-02
feature: create selector
2 parents 0133f18 + cce7469 commit 3bd6f4c

File tree

20 files changed

+26925
-21
lines changed

20 files changed

+26925
-21
lines changed

cookbook-react/package-lock.json

+26,245-9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cookbook-react/package.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,23 @@
6464
"@types/react-spinkit": "^3.0.6",
6565
"@types/seamless-immutable": "^7.1.15",
6666
"@types/webpack-env": "^1.16.0",
67+
"@typescript-eslint/eslint-plugin": "4.14.2",
6768
"@typescript-eslint/parser": "4.14.2",
69+
"@wolox/eslint-config": "^1.0.0",
70+
"@wolox/eslint-config-react": "^1.0.0",
6871
"@wolox/eslint-config-typescript": "^2.0.0",
6972
"aws-deploy-script-fe": "^1.0.8",
7073
"clsx": "^1.1.1",
7174
"env-cmd": "^10.1.0",
72-
"@wolox/eslint-config": "^1.0.0",
73-
"@wolox/eslint-config-react": "^1.0.0",
7475
"eslint": "7.19.0",
7576
"eslint-import-resolver-typescript": "^2.3.0",
7677
"eslint-plugin-babel": "^5.3.1",
7778
"eslint-plugin-import": "^2.22.1",
7879
"eslint-plugin-jsx-a11y": "^6.4.1",
7980
"eslint-plugin-prettier": "^3.3.1",
8081
"eslint-plugin-react": "^7.22.0",
81-
"eslint-plugin-testing-library": "^3.10.1",
8282
"eslint-plugin-react-hooks": "4.2.0",
83-
"@typescript-eslint/eslint-plugin": "4.14.2",
83+
"eslint-plugin-testing-library": "^3.10.1",
8484
"husky": "^4.3.8",
8585
"minimist": "^1.2.0",
8686
"msw": "^0.26.2",
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"thumbnail": {
33
"type": "img",
4-
"url": "https://raw.githubusercontent.com/Wolox/frontend-cookbook/master/cookbook-react/assets/img_react.png"
4+
"url": "https://cookbook.wolox-fearmy.vercel.app/logo192.png"
55
}
66
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
## useUniqueSelection Hook
2+
3+
Hook with the logic to save an item identifier. Specially useful to handle radio buttons or tabs. The active/selected element can be controlled either from the parent or internally from the hook itself.
4+
5+
**Parameters**
6+
7+
| Variable | Type | Description |
8+
|:---|:---|:---:|:---|
9+
| onChange | (id: Id) => void | Function that will be executed when the selected id changes, returning the newly selected id |
10+
| active | Id (optional) | Pass this option if you want to handle the active value from the hook's caller instead of internally |
11+
| initialValue | Id (optional) | Pass this value if you want to have an initial selected value |
12+
13+
**Return values**
14+
15+
| Variable | Type | Description |
16+
|:---|:---|:---:|:---|
17+
| handleChange | (id: Id) => void | Function to pass to the selector each time it changes |
18+
| activeValue | Id | Current selected value |
19+
20+
**Usage**
21+
22+
```jsx
23+
// src/config/api is where we usually store this config
24+
import api, { setupRequestMonitor, STATUS_CODES } from './useUniqueSelection';
25+
26+
const options = [{ id: 1, name: 'option_1' }, { id: 1, title: 'Option 1' }]
27+
28+
// Internally managed
29+
function MyComponent() {
30+
const onItemChange = (id: number) => console.log('Selected item: ', id);
31+
const { handleChange, activeValue } = useUniqueSelection({ onChange: onItemChange, initialValue: 1 });
32+
33+
return (
34+
<div>
35+
{options.map(option => (
36+
<input
37+
type="radio"
38+
name={option.name}
39+
onChange={() => handleChange(option.id)}
40+
checked={activeValue === option.id}
41+
value={option.id} />
42+
))}
43+
</div>
44+
)
45+
}
46+
47+
// Externanlly managed
48+
function MyComponent() {
49+
const [active, setActive] = useState(1);
50+
const onItemChange = (id: number) => {
51+
setActive(id);
52+
console.log('Selected item: ', id)
53+
};
54+
const { handleChange, activeValue } = useUniqueSelection({ onChange: onItemChange, active: active });
55+
56+
return (
57+
<div>
58+
{options.map(option => (
59+
<input
60+
type="radio"
61+
name={option.name}
62+
onChange={() => handleChange(option.id)}
63+
checked={activeValue === option.id}
64+
value={option.id} />
65+
))}
66+
</div>
67+
)
68+
}
69+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"thumbnail": {
3+
"type": "img",
4+
"url": "https://cookbook.wolox-fearmy.vercel.app/logo192.png"
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useState } from 'react';
2+
3+
export type OptionId = string | number;
4+
5+
interface Params<Id> {
6+
onChange: (id: Id) => void;
7+
active?: Id;
8+
initialValue?: Id;
9+
}
10+
11+
const useUniqueSelection = <Id extends OptionId>({ onChange, active, initialValue }: Params<Id>) => {
12+
const [internalActiveTab, setInternalActiveTab] = useState<Id | undefined>(active || initialValue);
13+
14+
const activeValue = active || internalActiveTab;
15+
16+
const handleChange = (id: Id) => {
17+
if (!active) {
18+
setInternalActiveTab(id);
19+
}
20+
onChange(id);
21+
};
22+
23+
return { activeValue, handleChange };
24+
};
25+
26+
export default useUniqueSelection;

cookbook-react/src/recipes/inputs/mendoza/components/SelectableItem/index.test.tsx

+6-2
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ const item = { id: 'first_item', title: 'First item', subtitle: 'Small', price:
88

99
describe('when the selectable item is a radio', () => {
1010
test('Should match snapshot', () => {
11-
const { container } = render(<SelectableItem onChange={jest.fn()} selected={false} item={item} type="radio" />);
11+
const { container } = render(
12+
<SelectableItem onChange={jest.fn()} selected={false} item={item} type="radio" />
13+
);
1214
expect(container).toMatchSnapshot();
1315
});
1416
});
1517

1618
describe('when the selectable item is a checkbox', () => {
1719
test('Should match snapshot', () => {
18-
const { container } = render(<SelectableItem onChange={jest.fn()} selected={false} item={item} type="checkbox" />);
20+
const { container } = render(
21+
<SelectableItem onChange={jest.fn()} selected={false} item={item} type="checkbox" />
22+
);
1923
expect(container).toMatchSnapshot();
2024
});
2125
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
5+
import Selector from './index';
6+
7+
const options = [
8+
{ id: 1, title: 'Opción 1', disabled: false },
9+
{ id: 2, title: 'Opción 2', disabled: true },
10+
{ id: 3, title: 'Opción 3', disabled: false }
11+
];
12+
13+
describe('When tab is internally controlled', () => {
14+
const handleChange = jest.fn();
15+
16+
beforeEach(() => {
17+
render(<Selector options={options} onChange={handleChange} />);
18+
});
19+
20+
test('it selects a tab when user clicks it and the rest is not selected', () => {
21+
const tabToSelect = screen.getByText('Opción 1');
22+
userEvent.click(tabToSelect);
23+
expect(handleChange).toHaveBeenCalledTimes(1);
24+
expect(handleChange).toHaveBeenCalledWith(1);
25+
expect(tabToSelect).toHaveClass('active');
26+
});
27+
28+
test('can not select a disabled option', () => {
29+
userEvent.click(screen.getByText('Opción 2'));
30+
expect(handleChange).not.toHaveBeenCalled();
31+
});
32+
});
33+
34+
describe('When tab is externally controlled', () => {
35+
const handleChange = jest.fn();
36+
37+
beforeEach(() => {
38+
render(<Selector options={options} onChange={handleChange} active={1} />);
39+
});
40+
41+
test('clicking does not select an element', () => {
42+
const currentSelectedTab = screen.getByText('Opción 1');
43+
const tabToSelect = screen.getByText('Opción 3');
44+
userEvent.click(tabToSelect);
45+
expect(currentSelectedTab).toHaveClass('active');
46+
expect(tabToSelect).not.toHaveClass('active');
47+
});
48+
49+
test('can not select a disabled option', () => {
50+
userEvent.click(screen.getByText('Opción 2'));
51+
expect(handleChange).not.toHaveBeenCalled();
52+
});
53+
});
54+
55+
describe('When the tab has initial value and it is internally controlled', () => {
56+
test('starts with the initial value selected', () => {
57+
render(<Selector options={options} onChange={jest.fn()} initialValue={3} />);
58+
const tab = screen.getByText('Opción 3');
59+
expect(tab).toHaveClass('active');
60+
});
61+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React, { Fragment } from 'react';
2+
import cn from 'classnames';
3+
4+
import useUniqueSelection, { OptionId } from '../../../hooks/useUniqueSelection';
5+
6+
import styles from './styles.module.scss';
7+
8+
interface Option<Id extends OptionId> {
9+
title: string;
10+
disabled?: boolean;
11+
id: Id;
12+
}
13+
14+
interface Props<Id extends OptionId> {
15+
options: Option<Id>[];
16+
onChange: (id: Id) => void;
17+
className?: string;
18+
}
19+
20+
interface InternallyControlledProps<Id extends OptionId> extends Props<Id> {
21+
active?: never;
22+
initialValue?: Id;
23+
}
24+
25+
interface ExternallyControlledProps<Id extends OptionId> extends Props<Id> {
26+
// Optional value to allow external change of tab. If empty, the component sets the clicked
27+
// tab as the active one.
28+
active: Id;
29+
initialValue?: never;
30+
}
31+
32+
function Selector<Id extends OptionId>({
33+
className = '',
34+
active,
35+
onChange,
36+
options,
37+
initialValue
38+
}: InternallyControlledProps<Id> | ExternallyControlledProps<Id>) {
39+
const { activeValue, handleChange } = useUniqueSelection({ onChange, active, initialValue });
40+
41+
const nextValueIsSelected = (index: number) =>
42+
options.length > index + 1 && options[index + 1].id === activeValue;
43+
44+
return (
45+
<div className={cn(styles.optionsContainer, className)}>
46+
{options?.map((option, index) => (
47+
<Fragment key={`${option.id}`}>
48+
<label
49+
htmlFor={`option-${option.id}`}
50+
key={option.id}
51+
className={cn(styles.optionLabel, {
52+
[styles.disabled]: option.disabled,
53+
[styles.active]: option.id === activeValue
54+
})}
55+
>
56+
<input
57+
type="radio"
58+
id={`option-${option.id}`}
59+
name="option"
60+
checked={activeValue === option.id}
61+
className={styles.option}
62+
disabled={option.disabled}
63+
value={option.id}
64+
onChange={() => handleChange(option.id)}
65+
/>
66+
{option.title}
67+
</label>
68+
{index < options.length - 1 && (
69+
<div
70+
className={cn(styles.separator, {
71+
[styles.active]: nextValueIsSelected(index)
72+
})}
73+
/>
74+
)}
75+
</Fragment>
76+
))}
77+
</div>
78+
);
79+
}
80+
81+
export default Selector;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
$unselected-color: #E1E1E1;
2+
$text-color: #000;
3+
$selected-color: #003DD5;
4+
$btn-height: 45px;
5+
$options: 3;
6+
$btn-width: (100 / $options) * 1%;
7+
8+
$transition-time: 0.25s;
9+
10+
.options-container {
11+
display: flex;
12+
}
13+
14+
.separator {
15+
background-color: $unselected-color;
16+
min-height: 100%;
17+
min-width: 3px;
18+
transition: background-color $transition-time ease;
19+
20+
&.active {
21+
background-color: $selected-color;
22+
}
23+
}
24+
25+
.option-label {
26+
align-items: center;
27+
border-top: 3px solid $unselected-color;
28+
border-bottom: 3px solid $unselected-color;
29+
color: $text-color;
30+
cursor: pointer;
31+
display: flex;
32+
font-weight: 500;
33+
height: $btn-height;
34+
justify-content: center;
35+
min-width: 100px;
36+
transition: border-color $transition-time ease, color $transition-time ease;
37+
width: $btn-width;
38+
39+
&:first-child {
40+
border-left: 3px solid $unselected-color;
41+
border-radius: 5px 0 0 5px;
42+
}
43+
44+
&:last-child {
45+
border-right: 3px solid $unselected-color;
46+
border-radius: 0 5px 5px 0;
47+
}
48+
49+
&:only-child {
50+
border-radius: 5px;
51+
}
52+
53+
&.disabled {
54+
color: $unselected-color;
55+
font-weight: 600;
56+
}
57+
58+
&.active {
59+
border-color: $selected-color;
60+
color: $selected-color;
61+
font-weight: 600;
62+
}
63+
64+
&.active + .separator {
65+
background-color: $selected-color;
66+
}
67+
}
68+
69+
.option {
70+
display: none;
71+
}

0 commit comments

Comments
 (0)