Skip to content

Commit

Permalink
feat: support custom icon and clearIcon props (#297)
Browse files Browse the repository at this point in the history
* test: add and configure jest-dom

* feat(datepicker): allow custom icon and clear icon as components

* test(datepicker): assert that custom icons work as expected

* chore(stories): add selects for custom icon and clearIcon props

* chore: improve type-checking and add story for custom icons

* docs: add icon and clearIcon props to README
  • Loading branch information
arthurdenner committed Jun 29, 2020
1 parent 33cbf6e commit eb28dbf
Show file tree
Hide file tree
Showing 11 changed files with 280 additions and 42 deletions.
39 changes: 20 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,31 +73,32 @@ More examples [here](https://react-semantic-ui-datepickers.now.sh).

### Own Props

| property | type | required | default | description |
| -------------------- | ------------ | -------- | ------------ | --------------------------------------------------------------------------------------------------------------- |
| allowOnlyNumbers | boolean | no | true | Allows the user enter only numbers |
| autoComplete | string | no | -- | Specifies if the input should have autocomplete enabled |
| clearOnSameDateClick | boolean | no | true | Controls whether the datepicker's state resets if the same date is selected in succession. |
| clearable | boolean | no | true | Allows the user to clear the value |
| filterDate | function | no | () => true | Function that receives each date and returns a boolean to indicate whether it is enabled or not |
| format | string | no | 'YYYY-MM-DD' | Specifies how the date will be formatted using the [date-fns' format](https://date-fns.org/v1.29.0/docs/format) |
| keepOpenOnClear | boolean | no | false | Keeps the datepicker open (or opens it if it's closed) when the clear icon is clicked |
| keepOpenOnSelect | boolean | no | false | Keeps the datepicker open when a date is selected |
| inline | boolean | no | false | Uses an inline variant, without the input and the features related to it, e.g. clearable datepicker |
| locale | string | no | 'en-US' | Filename of the locale to be used. PS: Feel free to submit PR's with more locales! |
| onBlur | function | no | () => {} | Callback fired when the input loses focus |
| onChange | function | no | () => {} | Callback fired when the value changes |
| pointing | string | no | 'left' | Location to render the component around input. Available options: 'left', 'right', 'top left', 'top right' |
| type | string | no | basic | Type of input to render. Available options: 'basic' and 'range' |
| datePickerOnly | boolean | no | false | Allows the date to be selected only via the date picker and disables the text input |
| value | Date\|Date[] | no | -- | The value of the datepicker |
| property | type | required | default | description |
| -------------------- | ----------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| allowOnlyNumbers | boolean | no | true | Allows the user enter only numbers |
| autoComplete | string | no | -- | Specifies if the input should have autocomplete enabled |
| clearIcon | SemanticICONS \| React.ReactElement | no | 'close' | An [icon from semantic-ui-react](https://react.semantic-ui.com/elements/icon/) or a custom component. The custom component will get two props: `data-testid` and `onClick`. |
| clearOnSameDateClick | boolean | no | true | Controls whether the datepicker's state resets if the same date is selected in succession. |
| clearable | boolean | no | true | Allows the user to clear the value |
| datePickerOnly | boolean | no | false | Allows the date to be selected only via the date picker and disables the text input |
| filterDate | function | no | () => true | Function that receives each date and returns a boolean to indicate whether it is enabled or not |
| format | string | no | 'YYYY-MM-DD' | Specifies how the date will be formatted using the [date-fns' format](https://date-fns.org/v1.29.0/docs/format) |
| icon | SemanticICONS \| React.ReactElement | no | 'calendar' | An [icon from semantic-ui-react](https://react.semantic-ui.com/elements/icon/) or a custom component. The custom component will get two props: `data-testid` and `onClick`. |
| inline | boolean | no | false | Uses an inline variant, without the input and the features related to it, e.g. clearable datepicker |
| keepOpenOnClear | boolean | no | false | Keeps the datepicker open (or opens it if it's closed) when the clear icon is clicked |
| keepOpenOnSelect | boolean | no | false | Keeps the datepicker open when a date is selected |
| locale | string | no | 'en-US' | Filename of the locale to be used. PS: Feel free to submit PR's with more locales! |
| onBlur | function | no | () => {} | Callback fired when the input loses focus |
| onChange | function | no | () => {} | Callback fired when the value changes |
| pointing | string | no | 'left' | Location to render the component around input. Available options: 'left', 'right', 'top left', 'top right' |
| type | string | no | basic | Type of input to render. Available options: 'basic' and 'range' |
| value | Date\|Date[] | no | -- | The value of the datepicker |

### Form.Input Props

- autoComplete
- disabled
- error
- icon
- iconPosition
- id
- label
Expand Down
1 change: 1 addition & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@storybook/addon-links": "5.3.19",
"@storybook/addons": "5.3.19",
"@storybook/react": "5.3.19",
"@testing-library/jest-dom": "5.10.1",
"@testing-library/react": "10.2.1",
"@types/jest": "26.0.0",
"@types/storybook__react": "5.2.1",
Expand Down Expand Up @@ -84,6 +85,7 @@
"tsConfig": "tsconfig.test.json"
}
},
"setupFilesAfterEnv": ["./jest.setup.ts"],
"transform": {
".+\\.css$": "jest-transform-css",
".(js|ts)x?": "ts-jest"
Expand Down
75 changes: 70 additions & 5 deletions src/__tests__/datepicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ import DatePicker from '../';
const setup = (props: Partial<SemanticDatepickerProps> = {}) => {
const options = render(<DatePicker onChange={jest.fn()} {...props} />);
const getQuery = props.inline ? options.queryByTestId : options.getByTestId;
const getIcon = () => options.getByTestId('datepicker-icon');

return {
...options,
openDatePicker: () => {
const icon = options.getByTestId('datepicker-icon');

fireEvent.click(icon);
},
getIcon,
openDatePicker: () => fireEvent.click(getIcon()),
rerender: (newProps?: Partial<SemanticDatepickerProps>) =>
options.rerender(
<DatePicker onChange={jest.fn()} {...props} {...newProps} />
Expand Down Expand Up @@ -488,4 +486,71 @@ describe('Inline datepicker', () => {
);
});
});

describe('Custom icons', () => {
it('should allow for custom Semantic UI icons', () => {
const icon = 'search';
const { getByText, getIcon, openDatePicker } = setup({ icon });

// Assert custom icon
expect(getIcon()).toHaveClass(icon, 'icon');
// Select current date
openDatePicker();
fireEvent.click(getByText('Today'));
// Assert datepicker is clearable
expect(getIcon()).toHaveClass('close', 'icon');
fireEvent.click(getIcon());
// Assert datepicker was cleared
expect(getIcon()).toHaveClass(icon, 'icon');
});

it('should allow for custom icon components', () => {
const { getByText, getIcon, openDatePicker } = setup({
icon: <span>Custom icon</span>,
});

// Assert custom icon
expect(getIcon().textContent).toBe('Custom icon');
// Select current date
openDatePicker();
fireEvent.click(getByText('Today'));
// Assert datepicker is clearable
expect(getIcon()).toHaveClass('close', 'icon');
fireEvent.click(getIcon());
// Assert datepicker was cleared
expect(getIcon().textContent).toBe('Custom icon');
});

it('should allow for custom clear Semantic UI icons', () => {
const clearIcon = 'ban';
const { getByText, getIcon, openDatePicker } = setup({ clearIcon });

// Select current date
openDatePicker();
fireEvent.click(getByText('Today'));
// Assert custom icon
expect(getIcon()).toHaveClass(clearIcon, 'icon');
// Assert datepicker is clearable
fireEvent.click(getIcon());
// Assert datepicker was cleared
expect(getIcon()).toHaveClass('calendar', 'icon');
});

it('should allow for custom clear icon components', () => {
const customClearIcon = <span>Custom icon</span>;
const { getByText, getIcon, openDatePicker } = setup({
clearIcon: customClearIcon,
});

// Select current date
openDatePicker();
fireEvent.click(getByText('Today'));
// Assert custom icon
expect(getIcon().textContent).toBe('Custom icon');
// Assert datepicker is clearable
fireEvent.click(getIcon());
// Assert datepicker was cleared
expect(getIcon()).toHaveClass('calendar', 'icon');
});
});
});
55 changes: 55 additions & 0 deletions src/components/icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';
import { Icon as SUIIcon } from 'semantic-ui-react';
import { SemanticDatepickerProps } from '../types';

type CustomIconProps = {
clearIcon: SemanticDatepickerProps['clearIcon'];
icon: SemanticDatepickerProps['icon'];
isClearIconVisible: boolean;
onClear: () => void;
onClick: () => void;
};

const CustomIcon = ({
clearIcon,
icon,
isClearIconVisible,
onClear,
onClick,
}: CustomIconProps) => {
if (isClearIconVisible && clearIcon && React.isValidElement(clearIcon)) {
return React.cloneElement(clearIcon, {
'data-testid': 'datepicker-icon',
onClick: onClear,
});
}

if (isClearIconVisible && clearIcon && !React.isValidElement(clearIcon)) {
return (
<SUIIcon
data-testid="datepicker-icon"
link
name={clearIcon}
onClick={onClear}
/>
);
}

if (icon && React.isValidElement(icon)) {
return React.cloneElement(icon, {
'data-testid': 'datepicker-icon',
onClick,
});
}

return (
<SUIIcon data-testid="datepicker-icon" link name={icon} onClick={onClick} />
);
};

CustomIcon.defaultProps = {
clearIcon: 'close',
icon: 'calendar',
};

export default CustomIcon;
22 changes: 12 additions & 10 deletions src/components/input.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import React from 'react';
import { Form, Icon, Input, FormInputProps } from 'semantic-ui-react';
import { Form, Input, FormInputProps } from 'semantic-ui-react';
import { SemanticDatepickerProps } from '../types';
import CustomIcon from './icon';

type InputProps = FormInputProps & {
clearIcon: SemanticDatepickerProps['clearIcon'];
icon: SemanticDatepickerProps['icon'];
isClearIconVisible: boolean;
};

const CustomInput = React.forwardRef<Input, InputProps>((props, ref) => {
const {
clearIcon,
icon,
isClearIconVisible,
label,
Expand All @@ -24,11 +29,12 @@ const CustomInput = React.forwardRef<Input, InputProps>((props, ref) => {
{...rest}
ref={ref}
icon={
<Icon
data-testid="datepicker-icon"
link
name={isClearIconVisible ? 'close' : icon}
onClick={isClearIconVisible ? onClear : onClick}
<CustomIcon
clearIcon={clearIcon}
icon={icon}
isClearIconVisible={isClearIconVisible}
onClear={onClear}
onClick={onClick}
/>
}
onClick={onClick}
Expand All @@ -38,8 +44,4 @@ const CustomInput = React.forwardRef<Input, InputProps>((props, ref) => {
);
});

CustomInput.defaultProps = {
icon: 'calendar',
};

export default CustomInput;
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const style: React.CSSProperties = {
};
const semanticInputProps = [
'autoComplete',
'clearIcon',
'disabled',
'error',
'icon',
Expand Down
5 changes: 3 additions & 2 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FormInputProps } from 'semantic-ui-react';
import { FormInputProps, SemanticICONS } from 'semantic-ui-react';

export type Object = { [key: string]: any };

Expand Down Expand Up @@ -41,7 +41,6 @@ export type PickedFormInputProps = Pick<
FormInputProps,
| 'disabled'
| 'error'
| 'icon'
| 'iconPosition'
| 'id'
| 'label'
Expand All @@ -59,10 +58,12 @@ export type SemanticDatepickerProps = PickedDayzedProps &
autoComplete?: string;
clearOnSameDateClick: boolean;
clearable: boolean;
clearIcon?: SemanticICONS | React.ReactElement;
filterDate: (date: Date) => boolean;
format: string;
keepOpenOnClear: boolean;
keepOpenOnSelect: boolean;
icon?: SemanticICONS | React.ReactElement;
inline: boolean;
locale: LocaleOptions;
onBlur: (event?: React.SyntheticEvent) => void;
Expand Down
7 changes: 7 additions & 0 deletions stories/data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { ALL_ICONS_IN_ALL_CONTEXTS } from 'semantic-ui-react/src/lib/SUI';
import { SemanticICONS } from 'semantic-ui-react';

const types = <const>['basic', 'range'];
const pointing = <const>['left', 'right', 'top left', 'top right'];
const locale = <const>[
Expand Down Expand Up @@ -35,3 +38,7 @@ export const typeMap = arrayToMap(types);
export const pointingMap = arrayToMap(pointing);

export const localeMap = arrayToMap([...locale].sort());

export const iconMap = arrayToMap<SemanticICONS>(
ALL_ICONS_IN_ALL_CONTEXTS.sort()
);
33 changes: 30 additions & 3 deletions stories/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import {
text,
} from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { Form } from 'semantic-ui-react';
import { Form, SemanticICONS } from 'semantic-ui-react';
import 'semantic-ui-css/semantic.min.css';
import SemanticDatepicker from '../src';
import {
Content,
iconMap,
isWeekday,
localeMap,
onChange,
Expand All @@ -37,14 +38,17 @@ stories.add('Basic usage', () => {
const inline = boolean('Inline (without input)', false);
const allowOnlyNumbers = boolean('Allow only numbers', false);
const clearOnSameDateClick = boolean('Clear on same date click', true);
const clearable = boolean('Clearable', true);
const icon = select('Icon (without value)', iconMap, iconMap.calendar);
const clearIcon = select('Clear icon (with value)', iconMap, iconMap.close);
const iconOnLeft = boolean('Icon on the left', false);
const datePickerOnly = boolean('Datepicker only', false);
const firstDayOfWeek = number('First day of week', 0, { max: 6, min: 0 });
const format = text('Format', 'YYYY-MM-DD');
const keepOpenOnClear = boolean('Keep open on clear', false);
const keepOpenOnSelect = boolean('Keep open on select', false);
const locale = select('Locale', localeMap, localeMap['en-US']);
const pointing = select('Pointing', pointingMap, pointingMap.left);
const clearable = boolean('Clearable', true);
const readOnly = boolean('Read-only', false);
const showOutsideDays = boolean('Show outside days', false);
const minDate = new Date(date('Min date', new Date('2018-01-01')));
Expand All @@ -64,15 +68,18 @@ stories.add('Basic usage', () => {
<SemanticDatepicker
key={key}
allowOnlyNumbers={allowOnlyNumbers}
clearIcon={clearIcon}
clearOnSameDateClick={clearOnSameDateClick}
clearable={clearable}
datePickerOnly={datePickerOnly}
filterDate={filterDate}
firstDayOfWeek={firstDayOfWeek}
format={format}
icon={icon}
iconPosition={iconOnLeft ? 'left' : undefined}
inline={inline}
keepOpenOnClear={keepOpenOnClear}
keepOpenOnSelect={keepOpenOnSelect}
inline={inline}
locale={locale}
maxDate={maxDate}
minDate={minDate}
Expand All @@ -87,6 +94,26 @@ stories.add('Basic usage', () => {
);
});

stories.add('With custom icons', () => {
const icon = select('Icon (without value)', iconMap, iconMap.calendar);
const clearIcon = select('Clear icon (with value)', iconMap, iconMap.close);
const useCustomIcon = boolean('Custom icon', false);
const useCustomClearIcon = boolean('Custom clear icon', false);
const CustomIcon = (props: any) => <button {...props}>Select</button>;
const CustomClearIcon = (props: any) => <button {...props}>Reset</button>;
const x = useCustomIcon ? ((<CustomIcon />) as unknown) : icon;
const y = useCustomClearIcon ? ((<CustomClearIcon />) as unknown) : clearIcon;

return (
<Content>
<SemanticDatepicker
clearIcon={y as SemanticICONS | React.ReactElement}
icon={x as SemanticICONS | React.ReactElement}
/>
</Content>
);
});

stories.add('Usage with Form', () => {
return (
<Content>
Expand Down

1 comment on commit eb28dbf

@vercel
Copy link

@vercel vercel bot commented on eb28dbf Jun 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.