@bitrise/bitkit 10.34.0 → 10.35.0-alpha-datepicker-rewrite.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 +11 -20
- package/src/Components/DatePicker/DatePicker.context.ts +14 -0
- package/src/Components/DatePicker/DatePicker.stories.tsx +16 -0
- package/src/Components/DatePicker/DatePicker.test.tsx +135 -0
- package/src/Components/DatePicker/DatePicker.tsx +178 -0
- package/src/Components/DatePicker/DatePickerDay.theme.ts +108 -0
- package/src/Components/DatePicker/DatePickerDay.tsx +137 -0
- package/src/Components/DatePicker/DatePickerFooter.tsx +41 -0
- package/src/Components/DatePicker/DatePickerGrid.tsx +10 -0
- package/src/Components/DatePicker/DatePickerHeader.tsx +72 -0
- package/src/Components/DatePicker/DatePickerMonth.tsx +94 -0
- package/src/Components/DatePicker/DatePickerMonthSelector.tsx +132 -0
- package/src/Components/DatePicker/__snapshots__/DatePicker.test.tsx.snap +7534 -0
- package/src/Components/DatePicker/useDateRange.ts +48 -0
- package/src/Components/DatePicker/useViewDate.ts +35 -0
- package/src/Components/NumberInput/NumberInput.theme.ts +36 -0
- package/src/Foundations/Shadows/Shadows.ts +1 -0
- package/src/Foundations/Sizes/Sizes.ts +1 -0
- package/src/Old/hooks/index.ts +0 -1
- package/src/index.ts +3 -0
- package/src/old.ts +0 -3
- package/src/theme.ts +4 -0
- package/src/tsconfig.tsbuildinfo +1 -1
- package/src/utils/utils.ts +8 -0
- package/src/Old/DatePicker/DatePicker.css +0 -74
- package/src/Old/DatePicker/DatePicker.tsx +0 -194
- package/src/Old/DatePicker/DatePickerDay.tsx +0 -72
- package/src/Old/DatePicker/DatePickerGrid.tsx +0 -12
- package/src/Old/DatePicker/DatePickerMonth.tsx +0 -87
- package/src/Old/hooks/useMediaQuery.ts +0 -91
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitrise/bitkit",
|
|
3
3
|
"description": "Bitrise React component library",
|
|
4
|
-
"version": "10.
|
|
4
|
+
"version": "10.35.0-alpha-datepicker-rewrite.1",
|
|
5
5
|
"repository": "git@github.com:bitrise-io/bitkit.git",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"license": "UNLICENSED",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build:icons": "ts-node ./scripts/build-icons.ts",
|
|
10
10
|
"commitlint": "commitlint",
|
|
11
|
-
"lint": "eslint src
|
|
11
|
+
"lint": "eslint src --ext ts,tsx",
|
|
12
12
|
"semantic-release": "semantic-release",
|
|
13
13
|
"start": "yarn storybook",
|
|
14
|
-
"test": "jest
|
|
14
|
+
"test": "jest",
|
|
15
15
|
"storybook": "start-storybook -p 6006",
|
|
16
16
|
"build-storybook": "build-storybook"
|
|
17
17
|
},
|
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
"@popperjs/core": "^2.11.5",
|
|
31
31
|
"classnames": "^2.3.1",
|
|
32
32
|
"framer-motion": "^6.5.1",
|
|
33
|
-
"luxon": "^
|
|
33
|
+
"luxon": "^3.0.3",
|
|
34
34
|
"react": "^18.2.0",
|
|
35
35
|
"react-dom": "^18.2.0",
|
|
36
|
+
"react-focus-lock": "^2.9.1",
|
|
36
37
|
"react-popper": "^2.3.0"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
@@ -46,6 +47,8 @@
|
|
|
46
47
|
"@chakra-ui/cli": "^2.1.2",
|
|
47
48
|
"@commitlint/cli": "^16.3.0",
|
|
48
49
|
"@commitlint/config-conventional": "^16.2.4",
|
|
50
|
+
"@emotion/cache": "^11.10.3",
|
|
51
|
+
"@emotion/jest": "^11.10.0",
|
|
49
52
|
"@google-cloud/storage": "^5.20.5",
|
|
50
53
|
"@semantic-release/changelog": "^6.0.1",
|
|
51
54
|
"@semantic-release/commit-analyzer": "^9.0.2",
|
|
@@ -64,10 +67,10 @@
|
|
|
64
67
|
"@testing-library/dom": "^8.16.0",
|
|
65
68
|
"@testing-library/jest-dom": "^5.16.4",
|
|
66
69
|
"@testing-library/react": "^13.3.0",
|
|
67
|
-
"@testing-library/user-event": "^14.3
|
|
70
|
+
"@testing-library/user-event": "^14.4.3",
|
|
68
71
|
"@types/cheerio": "^0.22.31",
|
|
69
72
|
"@types/enzyme": "^3.10.12",
|
|
70
|
-
"@types/jest": "^
|
|
73
|
+
"@types/jest": "^29.0.3",
|
|
71
74
|
"@types/luxon": "^2.4.0",
|
|
72
75
|
"@types/react": "18.0.18",
|
|
73
76
|
"@types/react-dom": "^18.0.6",
|
|
@@ -93,14 +96,14 @@
|
|
|
93
96
|
"glob": "^8.0.3",
|
|
94
97
|
"husky": "^7.0.4",
|
|
95
98
|
"identity-obj-proxy": "^3.0.0",
|
|
96
|
-
"jest": "^
|
|
99
|
+
"jest": "^29.0.3",
|
|
97
100
|
"jest-environment-jsdom": "^28.1.3",
|
|
98
101
|
"jsdom": "^19.0.0",
|
|
99
102
|
"prettier": "^2.7.1",
|
|
100
103
|
"react-hook-form": "^7.33.1",
|
|
101
104
|
"recast": "^0.21.1",
|
|
102
105
|
"semantic-release": "^19.0.3",
|
|
103
|
-
"ts-jest": "^
|
|
106
|
+
"ts-jest": "^29.0.1",
|
|
104
107
|
"ts-node": "^10.9.1",
|
|
105
108
|
"tsconfig-paths-webpack-plugin": "^3.5.2",
|
|
106
109
|
"typescript": "^4.8.2",
|
|
@@ -113,18 +116,6 @@
|
|
|
113
116
|
"publishConfig": {
|
|
114
117
|
"access": "public"
|
|
115
118
|
},
|
|
116
|
-
"jest": {
|
|
117
|
-
"moduleNameMapper": {
|
|
118
|
-
"^.+\\.css$": "identity-obj-proxy"
|
|
119
|
-
},
|
|
120
|
-
"setupFilesAfterEnv": [
|
|
121
|
-
"./spec/jest.setup.ts"
|
|
122
|
-
],
|
|
123
|
-
"transform": {
|
|
124
|
-
"\\.tsx?$": "ts-jest"
|
|
125
|
-
},
|
|
126
|
-
"testEnvironment": "./spec/test-env.js"
|
|
127
|
-
},
|
|
128
119
|
"resolutions": {
|
|
129
120
|
"**/ast-types": "npm:@gkz/ast-types",
|
|
130
121
|
"ts-node": "^10.9.1",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createContext } from '@chakra-ui/react-utils';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { DateRange } from './useDateRange';
|
|
4
|
+
|
|
5
|
+
interface Context {
|
|
6
|
+
selectable?: DateRange;
|
|
7
|
+
selected?: DateRange;
|
|
8
|
+
today: DateTime;
|
|
9
|
+
preview: 'from' | 'to' | undefined;
|
|
10
|
+
onSelect: (d: DateTime) => void;
|
|
11
|
+
showOutsideMonths: boolean;
|
|
12
|
+
onPreview: (d: DateTime) => void;
|
|
13
|
+
}
|
|
14
|
+
export const [DatePickerContext, useDatePickerContext] = createContext<Context>();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ComponentMeta } from '@storybook/react';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import DatePicker from './DatePicker';
|
|
4
|
+
import { DateRange } from './useDateRange';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
component: DatePicker,
|
|
8
|
+
args: { visible: true, children: <button type="button">ok</button> },
|
|
9
|
+
} as ComponentMeta<typeof DatePicker>;
|
|
10
|
+
|
|
11
|
+
export const Default = {};
|
|
12
|
+
export const Selectable = {
|
|
13
|
+
args: {
|
|
14
|
+
selectable: new DateRange(DateTime.local(2022, 11, 20), DateTime.local(2022, 11, 30)),
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { CacheProvider } from '@emotion/react';
|
|
4
|
+
import createCache from '@emotion/cache';
|
|
5
|
+
import userEventGlobal from '@testing-library/user-event';
|
|
6
|
+
import Provider from '../Provider/Provider';
|
|
7
|
+
import useResponsive from '../../hooks/useResponsive';
|
|
8
|
+
import DatePicker, { DatePickerProps } from './DatePicker';
|
|
9
|
+
import { DateRange } from './useDateRange';
|
|
10
|
+
|
|
11
|
+
const cache = createCache({ key: 'test', stylisPlugins: [] });
|
|
12
|
+
|
|
13
|
+
jest.mock('../../hooks/useResponsive', () => jest.fn(() => ({ isMobile: false })));
|
|
14
|
+
|
|
15
|
+
jest.useFakeTimers();
|
|
16
|
+
jest.setSystemTime(DateTime.local(2022, 1, 2).toJSDate());
|
|
17
|
+
const userEvent = userEventGlobal.setup({ advanceTimers: (t) => jest.advanceTimersByTime(t) });
|
|
18
|
+
const Subject = (props: Partial<DatePickerProps>) => (
|
|
19
|
+
<CacheProvider value={cache}>
|
|
20
|
+
<Provider>
|
|
21
|
+
<DatePicker onClose={() => {}} visible {...props}>
|
|
22
|
+
<div />
|
|
23
|
+
</DatePicker>
|
|
24
|
+
</Provider>
|
|
25
|
+
</CacheProvider>
|
|
26
|
+
);
|
|
27
|
+
describe('DatePicker', () => {
|
|
28
|
+
it('works', async () => {
|
|
29
|
+
const handler = jest.fn<void, [DateRange]>();
|
|
30
|
+
render(<Subject onApply={handler} />);
|
|
31
|
+
await userEvent.click(await screen.findByRole('button', { name: 'January' }));
|
|
32
|
+
await userEvent.selectOptions(
|
|
33
|
+
await screen.findByRole('listbox', { name: 'month' }),
|
|
34
|
+
await screen.findByRole('option', { name: 'October' }),
|
|
35
|
+
);
|
|
36
|
+
const month = await screen.findByRole('listbox', { name: 'October 2022' });
|
|
37
|
+
await userEvent.selectOptions(month, await within(month).findByRole('option', { name: '23' }));
|
|
38
|
+
await userEvent.click(await screen.findByRole('button', { name: 'November' }));
|
|
39
|
+
const year = await screen.findByRole('spinbutton', { name: 'year' });
|
|
40
|
+
await userEvent.type(year, '{BackSpace}3');
|
|
41
|
+
await userEvent.selectOptions(
|
|
42
|
+
await screen.findByRole('listbox', { name: 'month' }),
|
|
43
|
+
await screen.findByRole('option', { name: 'February' }),
|
|
44
|
+
);
|
|
45
|
+
const month2 = await screen.findByRole('listbox', { name: 'February 2023' });
|
|
46
|
+
await userEvent.selectOptions(month2, await within(month2).findByRole('option', { name: '10' }));
|
|
47
|
+
await userEvent.click(await screen.findByRole('button', { name: 'Apply' }));
|
|
48
|
+
expect(handler).toHaveBeenCalledWith(expect.any(DateRange));
|
|
49
|
+
const [{ from: begin, to: end }] = handler.mock.lastCall!;
|
|
50
|
+
expect(begin! < end!).toBe(true);
|
|
51
|
+
expect(begin?.toObject()).toMatchObject({ year: 2022, month: 10, day: 23 });
|
|
52
|
+
expect(end?.toObject()).toMatchObject({ year: 2023, month: 2, day: 10 });
|
|
53
|
+
});
|
|
54
|
+
describe('defaults', () => {
|
|
55
|
+
it('renders exactly as before', async () => {
|
|
56
|
+
render(<Subject />);
|
|
57
|
+
expect(document.body).toMatchSnapshot();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('with selectable range', () => {
|
|
61
|
+
it('renders exactly as before', async () => {
|
|
62
|
+
render(<Subject selectable={new DateRange(DateTime.local(2022, 2, 1), DateTime.local(2022, 2, 7))} />);
|
|
63
|
+
expect(document.body).toMatchSnapshot();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('with range pre-selected', () => {
|
|
67
|
+
describe('when range is entirely inside the current month', () => {
|
|
68
|
+
it('renders exactly as before', () => {
|
|
69
|
+
render(<Subject selected={new DateRange(DateTime.local(2022, 2, 1), DateTime.local(2022, 2, 7))} />);
|
|
70
|
+
expect(document.body).toMatchSnapshot();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('on mobile', () => {
|
|
74
|
+
describe('when range is partially in the next month', () => {
|
|
75
|
+
it('renders exactly as before', () => {
|
|
76
|
+
jest.mocked(useResponsive).mockReturnValueOnce({ isMobile: true } as any);
|
|
77
|
+
render(<Subject selected={new DateRange(DateTime.local(2022, 2, 20), DateTime.local(2022, 3, 3))} />);
|
|
78
|
+
expect(document.body).toMatchSnapshot();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('when range start/end is in the same month', () => {
|
|
83
|
+
it('shows the start month on the left and the next month on the right', async () => {
|
|
84
|
+
render(<Subject selected={new DateRange(DateTime.local(2022, 2, 1), DateTime.local(2022, 2, 7))} />);
|
|
85
|
+
const left = await screen.findByRole('listbox', { name: 'February 2022' });
|
|
86
|
+
expect(left).toBeInTheDocument();
|
|
87
|
+
const right = await screen.findByRole('listbox', { name: 'March 2022' });
|
|
88
|
+
expect(right).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('when range start/end is in consequitive months', () => {
|
|
92
|
+
it('shows the start month on the left and the next month on the right', async () => {
|
|
93
|
+
render(<Subject selected={new DateRange(DateTime.local(2022, 2, 1), DateTime.local(2022, 3, 7))} />);
|
|
94
|
+
const left = await screen.findByRole('listbox', { name: 'February 2022' });
|
|
95
|
+
expect(left).toBeInTheDocument();
|
|
96
|
+
const right = await screen.findByRole('listbox', { name: 'March 2022' });
|
|
97
|
+
expect(right).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('when range start/end have more than 1 month between them', () => {
|
|
101
|
+
it('shows the start month on the left and the next month on the right', async () => {
|
|
102
|
+
render(<Subject selected={new DateRange(DateTime.local(2022, 2, 1), DateTime.local(2022, 5, 7))} />);
|
|
103
|
+
const left = await screen.findByRole('listbox', { name: 'February 2022' });
|
|
104
|
+
expect(left).toBeInTheDocument();
|
|
105
|
+
const right = await screen.findByRole('listbox', { name: 'March 2022' });
|
|
106
|
+
expect(right).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('month selector', () => {
|
|
111
|
+
it('renders correctly', async () => {
|
|
112
|
+
render(<Subject />);
|
|
113
|
+
const left = await screen.findByRole('button', { name: 'January' });
|
|
114
|
+
await userEvent.click(left);
|
|
115
|
+
await screen.findByRole('spinbutton', { name: 'year' });
|
|
116
|
+
expect(document.body).toMatchSnapshot();
|
|
117
|
+
});
|
|
118
|
+
it('shows current month as selected', async () => {
|
|
119
|
+
render(<Subject />);
|
|
120
|
+
const left = await screen.findByRole('button', { name: 'January' });
|
|
121
|
+
await userEvent.click(left);
|
|
122
|
+
const jan = await screen.findByRole('option', { name: 'January' });
|
|
123
|
+
const feb = await screen.findByRole('option', { name: 'February' });
|
|
124
|
+
expect(jan).toHaveAttribute('aria-selected', 'true');
|
|
125
|
+
expect(feb).not.toHaveAttribute('aria-selected');
|
|
126
|
+
});
|
|
127
|
+
it('focuses the year input when clicked', async () => {
|
|
128
|
+
render(<Subject />);
|
|
129
|
+
const left = await screen.findByRole('button', { name: 'January' });
|
|
130
|
+
await userEvent.click(left);
|
|
131
|
+
const year = await screen.findByRole('spinbutton', { name: 'year' });
|
|
132
|
+
await waitFor(() => expect(year).toHaveFocus());
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import FocusLock from 'react-focus-lock';
|
|
4
|
+
import { PopoverAnchor } from '@chakra-ui/react';
|
|
5
|
+
import { useObjectMemo } from '../../utils/utils';
|
|
6
|
+
import Popover from '../Popover/Popover';
|
|
7
|
+
import PopoverContent from '../Popover/PopoverContent';
|
|
8
|
+
import Box from '../Box/Box';
|
|
9
|
+
import useResponsive from '../../hooks/useResponsive';
|
|
10
|
+
import DatePickerMonth from './DatePickerMonth';
|
|
11
|
+
import { DatePickerContext } from './DatePicker.context';
|
|
12
|
+
import DatePickerMonthSelector from './DatePickerMonthSelector';
|
|
13
|
+
import useDateRange, { DateRange } from './useDateRange';
|
|
14
|
+
import useViewDate from './useViewDate';
|
|
15
|
+
import DatePickerFooter from './DatePickerFooter';
|
|
16
|
+
|
|
17
|
+
export { useDateRange, DateRange };
|
|
18
|
+
|
|
19
|
+
export interface DatePickerProps {
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
selectable?: DateRange;
|
|
22
|
+
selected?: DateRange;
|
|
23
|
+
onApply?: (range: DateRange) => void;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
visible: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A simple date selection component, that supports a dual month view and
|
|
30
|
+
* range selection.
|
|
31
|
+
*/
|
|
32
|
+
const DatePicker = (props: DatePickerProps) => {
|
|
33
|
+
const { children, onApply, onClose, visible, selectable, selected } = props;
|
|
34
|
+
|
|
35
|
+
const { isMobile } = useResponsive();
|
|
36
|
+
const today = DateTime.now().startOf('day');
|
|
37
|
+
|
|
38
|
+
const [dateFrom, setDateFrom] = useState(selected?.from);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!selected?.from || !dateFrom?.equals(selected.from)) {
|
|
41
|
+
setDateFrom(selected?.from);
|
|
42
|
+
}
|
|
43
|
+
}, [selected]);
|
|
44
|
+
const [dateTo, setDateTo] = useState(selected?.to);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!selected?.to || !dateTo?.equals(selected.to)) {
|
|
47
|
+
setDateTo(selected?.to);
|
|
48
|
+
}
|
|
49
|
+
}, [selected]);
|
|
50
|
+
|
|
51
|
+
const handleClose = () => {
|
|
52
|
+
onClose();
|
|
53
|
+
setDateTo(undefined);
|
|
54
|
+
setDateFrom(undefined);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleApply = () => {
|
|
58
|
+
if (onApply) {
|
|
59
|
+
onApply(new DateRange(dateFrom, dateTo));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleClose();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const { leftViewDate, rightViewDate, updateLeftViewDate, updateRightViewDate } = useViewDate({
|
|
66
|
+
initalView: dateFrom || selectable?.from,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const [preview, setPreview] = useState<'from' | 'to' | undefined>(undefined);
|
|
70
|
+
const [isMonthSelector, setIsMonthSelector] = useState<'left' | 'right' | undefined>(undefined);
|
|
71
|
+
const handlePreview = useCallback(
|
|
72
|
+
(date: DateTime) => {
|
|
73
|
+
if (!preview) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (dateFrom) {
|
|
77
|
+
if (date > dateFrom) {
|
|
78
|
+
setPreview('to');
|
|
79
|
+
} else {
|
|
80
|
+
setPreview('from');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
setDateTo(date);
|
|
84
|
+
},
|
|
85
|
+
[preview, dateFrom, dateTo],
|
|
86
|
+
);
|
|
87
|
+
const handleSelect = useCallback(
|
|
88
|
+
(date: DateTime) => {
|
|
89
|
+
setPreview(undefined);
|
|
90
|
+
if (dateFrom && dateTo) {
|
|
91
|
+
if (!preview) {
|
|
92
|
+
setPreview('from');
|
|
93
|
+
setDateFrom(date);
|
|
94
|
+
setDateTo(undefined);
|
|
95
|
+
}
|
|
96
|
+
} else if (dateTo && date > dateTo) {
|
|
97
|
+
setDateFrom(dateTo);
|
|
98
|
+
setDateTo(date);
|
|
99
|
+
} else if (dateFrom && date < dateFrom) {
|
|
100
|
+
setDateFrom(date);
|
|
101
|
+
setDateTo(dateFrom);
|
|
102
|
+
} else if (dateFrom) {
|
|
103
|
+
setDateTo(date);
|
|
104
|
+
} else {
|
|
105
|
+
setPreview('from');
|
|
106
|
+
setDateFrom(date);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[dateFrom, dateTo, preview],
|
|
110
|
+
);
|
|
111
|
+
const currentSelected = useDateRange([dateFrom, dateTo]);
|
|
112
|
+
const ctx = useObjectMemo({
|
|
113
|
+
selectable,
|
|
114
|
+
selected: currentSelected,
|
|
115
|
+
preview,
|
|
116
|
+
showOutsideMonths: isMobile,
|
|
117
|
+
today,
|
|
118
|
+
onPreview: handlePreview,
|
|
119
|
+
onSelect: handleSelect,
|
|
120
|
+
});
|
|
121
|
+
const onMonthClickLeft = useCallback(() => setIsMonthSelector('left'), []);
|
|
122
|
+
const onMonthClickRight = useCallback(() => setIsMonthSelector('right'), []);
|
|
123
|
+
const onMonthSelected = useCallback(
|
|
124
|
+
(month: number, year: number) => {
|
|
125
|
+
if (isMonthSelector === 'left') {
|
|
126
|
+
updateLeftViewDate({ month, year });
|
|
127
|
+
}
|
|
128
|
+
if (isMonthSelector === 'right') {
|
|
129
|
+
updateRightViewDate({ month, year });
|
|
130
|
+
}
|
|
131
|
+
setIsMonthSelector(undefined);
|
|
132
|
+
},
|
|
133
|
+
[isMonthSelector],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Popover modifiers={[]} isOpen={visible} onClose={onClose} isLazy lazyBehavior="unmount">
|
|
138
|
+
<PopoverAnchor>{children}</PopoverAnchor>
|
|
139
|
+
<PopoverContent aria-label="select date range">
|
|
140
|
+
<FocusLock returnFocus>
|
|
141
|
+
<DatePickerContext value={ctx}>
|
|
142
|
+
<Box padding="24">
|
|
143
|
+
{isMonthSelector ? (
|
|
144
|
+
<DatePickerMonthSelector
|
|
145
|
+
onMonthSelected={onMonthSelected}
|
|
146
|
+
viewDate={isMonthSelector === 'left' ? leftViewDate : rightViewDate}
|
|
147
|
+
/>
|
|
148
|
+
) : (
|
|
149
|
+
<>
|
|
150
|
+
<Box display="flex" gap="32" marginBottom="24">
|
|
151
|
+
<DatePickerMonth
|
|
152
|
+
onMonthClick={onMonthClickLeft}
|
|
153
|
+
onViewDateChange={updateLeftViewDate}
|
|
154
|
+
controls={isMobile ? 'both' : 'left'}
|
|
155
|
+
viewDate={leftViewDate}
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
{!isMobile && (
|
|
159
|
+
<DatePickerMonth
|
|
160
|
+
onMonthClick={onMonthClickRight}
|
|
161
|
+
onViewDateChange={updateRightViewDate}
|
|
162
|
+
controls="right"
|
|
163
|
+
viewDate={rightViewDate}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
</Box>
|
|
167
|
+
<DatePickerFooter onApply={handleApply} onClose={handleClose} selected={selected} />
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
</Box>
|
|
171
|
+
</DatePickerContext>
|
|
172
|
+
</FocusLock>
|
|
173
|
+
</PopoverContent>
|
|
174
|
+
</Popover>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export default DatePicker;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { DatePickerDayViewProps } from './DatePickerDay';
|
|
2
|
+
|
|
3
|
+
const borderRadii: Record<string, string> = {
|
|
4
|
+
from: '0.5rem 0 0 0.5rem',
|
|
5
|
+
to: '0 0.5rem 0.5rem 0',
|
|
6
|
+
incomplete: '0.5rem',
|
|
7
|
+
};
|
|
8
|
+
function selectionBorder({ selection }: DatePickerDayViewProps) {
|
|
9
|
+
if (!selection || selection === 'between') {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
return { borderRadius: borderRadii[selection] };
|
|
13
|
+
}
|
|
14
|
+
function previewStyles({ selection, preview, currentMonth }: DatePickerDayViewProps) {
|
|
15
|
+
const beingMoved = selection === preview;
|
|
16
|
+
const endpoint = selection === 'from' || selection === 'to';
|
|
17
|
+
if (selection === 'incomplete') {
|
|
18
|
+
return {
|
|
19
|
+
backgroundColor: 'purple.50',
|
|
20
|
+
color: 'neutral.100',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (selection === 'between') {
|
|
24
|
+
return {
|
|
25
|
+
bg: 'purple.95',
|
|
26
|
+
color: currentMonth ? 'purple.10' : 'neutral.70',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (endpoint) {
|
|
31
|
+
if (beingMoved) {
|
|
32
|
+
return {
|
|
33
|
+
bg: currentMonth ? 'purple.90' : 'purple.93',
|
|
34
|
+
color: currentMonth ? 'purple.10' : 'neutral.70',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
bg: currentMonth ? 'purple.50' : 'purple.70',
|
|
39
|
+
color: currentMonth ? 'neutral.100' : 'purple.95',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
function selectionStyles({ selection, currentMonth }: DatePickerDayViewProps) {
|
|
45
|
+
if (!selection) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
if (selection === 'from' || selection === 'to' || selection === 'incomplete') {
|
|
49
|
+
return {
|
|
50
|
+
backgroundColor: currentMonth ? 'purple.50' : 'purple.70',
|
|
51
|
+
color: currentMonth ? 'neutral.100' : 'neutral.90',
|
|
52
|
+
'&:hover, &:focus-visible': {
|
|
53
|
+
outline: 'none',
|
|
54
|
+
boxShadow: 'none',
|
|
55
|
+
color: 'purple.90',
|
|
56
|
+
backgroundColor: 'purple.50',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (selection === 'between') {
|
|
61
|
+
return {
|
|
62
|
+
color: currentMonth ? 'purple.10' : 'neutral.70',
|
|
63
|
+
backgroundColor: 'purple.90',
|
|
64
|
+
'&:hover, &:focus-visible': {
|
|
65
|
+
outline: 'none',
|
|
66
|
+
boxShadow: 'none',
|
|
67
|
+
backgroundColor: 'purple.80',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { backgroundColor: 'purple.90' };
|
|
72
|
+
}
|
|
73
|
+
const DatePickerDay = {
|
|
74
|
+
parts: ['day', 'selection'],
|
|
75
|
+
baseStyle(props: DatePickerDayViewProps) {
|
|
76
|
+
const { today, selectable, currentMonth, showOutsideDays, preview, selection } = props;
|
|
77
|
+
return {
|
|
78
|
+
text: {
|
|
79
|
+
border: today ? '1.5px solid' : undefined,
|
|
80
|
+
borderColor: today ? 'purple.50' : undefined,
|
|
81
|
+
width: '40',
|
|
82
|
+
height: '32',
|
|
83
|
+
borderRadius: '8',
|
|
84
|
+
display: 'flex',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
justifyContent: 'center',
|
|
87
|
+
'&:hover, button:focus-visible > &': selectable
|
|
88
|
+
? {
|
|
89
|
+
outline: 'none',
|
|
90
|
+
boxShadow: 'none',
|
|
91
|
+
backgroundColor: selection ? undefined : 'purple.90',
|
|
92
|
+
}
|
|
93
|
+
: undefined,
|
|
94
|
+
},
|
|
95
|
+
day: {
|
|
96
|
+
color: !selectable || (!currentMonth && showOutsideDays) ? 'neutral.70' : 'purple.10',
|
|
97
|
+
_focusVisible: {
|
|
98
|
+
outline: 'none',
|
|
99
|
+
boxShadow: 'none',
|
|
100
|
+
},
|
|
101
|
+
cursor: !selectable ? 'default' : undefined,
|
|
102
|
+
...(preview ? previewStyles(props) : selectionStyles(props)),
|
|
103
|
+
...selectionBorder(props),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
export default DatePickerDay;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { ReactNode, useCallback } from 'react';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { BoxProps, useMultiStyleConfig } from '@chakra-ui/react';
|
|
4
|
+
import { createContext } from '@chakra-ui/react-utils';
|
|
5
|
+
import Box from '../Box/Box';
|
|
6
|
+
import Text from '../Text/Text';
|
|
7
|
+
import { useDatePickerContext } from './DatePicker.context';
|
|
8
|
+
|
|
9
|
+
interface Context {
|
|
10
|
+
onPreviousMonth: () => void;
|
|
11
|
+
onNextMonth: () => void;
|
|
12
|
+
viewDate: DateTime;
|
|
13
|
+
}
|
|
14
|
+
export interface DatePickerDayViewProps {
|
|
15
|
+
onClick: () => void;
|
|
16
|
+
onMouseEnter: () => void;
|
|
17
|
+
selectable: boolean;
|
|
18
|
+
today: boolean;
|
|
19
|
+
currentMonth: boolean;
|
|
20
|
+
showOutsideDays: boolean;
|
|
21
|
+
preview: 'from' | 'to' | undefined;
|
|
22
|
+
selection?: 'from' | 'to' | 'between' | 'incomplete';
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
const DatePickerDayView = ({
|
|
26
|
+
onClick,
|
|
27
|
+
onMouseEnter,
|
|
28
|
+
selectable,
|
|
29
|
+
selection,
|
|
30
|
+
showOutsideDays,
|
|
31
|
+
currentMonth,
|
|
32
|
+
today,
|
|
33
|
+
preview,
|
|
34
|
+
children,
|
|
35
|
+
}: DatePickerDayViewProps) => {
|
|
36
|
+
const { day, text } = useMultiStyleConfig('DatePickerDay', {
|
|
37
|
+
today,
|
|
38
|
+
selectable,
|
|
39
|
+
selection,
|
|
40
|
+
preview,
|
|
41
|
+
showOutsideDays,
|
|
42
|
+
currentMonth,
|
|
43
|
+
});
|
|
44
|
+
const ariaProps: BoxProps = {};
|
|
45
|
+
if (currentMonth) {
|
|
46
|
+
ariaProps.as = 'button';
|
|
47
|
+
ariaProps.role = 'option';
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
<Box {...ariaProps} sx={day} onMouseEnter={onMouseEnter} onFocus={onMouseEnter} onClick={onClick}>
|
|
51
|
+
<Text sx={text}>{children}</Text>
|
|
52
|
+
</Box>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
const [DatePickerDayContext, useDatePickerDayContext] = createContext<Context>();
|
|
56
|
+
export { DatePickerDayContext };
|
|
57
|
+
|
|
58
|
+
const DatePickerDay = ({ n }: { n: number }): JSX.Element => {
|
|
59
|
+
const {
|
|
60
|
+
preview,
|
|
61
|
+
selectable,
|
|
62
|
+
selected,
|
|
63
|
+
onPreview,
|
|
64
|
+
showOutsideMonths: showOutsideDays,
|
|
65
|
+
onSelect,
|
|
66
|
+
today,
|
|
67
|
+
} = useDatePickerContext();
|
|
68
|
+
const { viewDate, onPreviousMonth, onNextMonth } = useDatePickerDayContext();
|
|
69
|
+
const { from: dateSelectableFrom, to: dateSelectableTo } = selectable || {};
|
|
70
|
+
const { from: dateSelectedFrom, to: dateSelectedTo } = selected || {};
|
|
71
|
+
|
|
72
|
+
const date = viewDate.plus({ days: n - viewDate.weekday });
|
|
73
|
+
const daysInPreviousMonth = viewDate.minus({ month: 1 }).daysInMonth;
|
|
74
|
+
|
|
75
|
+
const dayOfWeek = viewDate.weekday;
|
|
76
|
+
const { daysInMonth } = viewDate;
|
|
77
|
+
|
|
78
|
+
const isPreviousMonth = n < dayOfWeek;
|
|
79
|
+
const isNextMonth = n - dayOfWeek >= daysInMonth;
|
|
80
|
+
const isCurrentMonth = !isPreviousMonth && !isNextMonth;
|
|
81
|
+
const isAfterSelectableFromDate = !dateSelectableFrom || date >= dateSelectableFrom;
|
|
82
|
+
const isBeforeSelectableToDate = !dateSelectableTo || date <= dateSelectableTo;
|
|
83
|
+
const isSelectable = isAfterSelectableFromDate && isBeforeSelectableToDate;
|
|
84
|
+
|
|
85
|
+
const hasSelectionRange = dateSelectedFrom && dateSelectedTo && !dateSelectedFrom.equals(dateSelectedTo);
|
|
86
|
+
|
|
87
|
+
let selection: 'from' | 'to' | 'between' | 'incomplete' | undefined;
|
|
88
|
+
const interactive = isCurrentMonth || showOutsideDays;
|
|
89
|
+
if (hasSelectionRange && interactive && date > dateSelectedFrom && date < dateSelectedTo) {
|
|
90
|
+
selection = 'between';
|
|
91
|
+
}
|
|
92
|
+
if (interactive && dateSelectedFrom && date.equals(dateSelectedFrom)) {
|
|
93
|
+
selection = hasSelectionRange ? 'from' : 'incomplete';
|
|
94
|
+
}
|
|
95
|
+
if (interactive && dateSelectedTo && date.equals(dateSelectedTo)) {
|
|
96
|
+
selection = hasSelectionRange ? 'to' : 'incomplete';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const onClick = useCallback(() => {
|
|
100
|
+
if (!isSelectable) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
onSelect(date);
|
|
104
|
+
if (isPreviousMonth) {
|
|
105
|
+
onPreviousMonth();
|
|
106
|
+
} else if (isNextMonth) {
|
|
107
|
+
onNextMonth();
|
|
108
|
+
}
|
|
109
|
+
}, [isPreviousMonth, isSelectable, onSelect, date, onPreviousMonth, onNextMonth]);
|
|
110
|
+
const onMouseEnter = useCallback(() => {
|
|
111
|
+
if (isSelectable) {
|
|
112
|
+
onPreview(date);
|
|
113
|
+
}
|
|
114
|
+
}, [onSelect, isSelectable, date]);
|
|
115
|
+
|
|
116
|
+
if (!isCurrentMonth && !showOutsideDays) {
|
|
117
|
+
return <div />;
|
|
118
|
+
}
|
|
119
|
+
return (
|
|
120
|
+
<DatePickerDayView
|
|
121
|
+
today={today.equals(viewDate.set({ day: n - dayOfWeek + 1 }).startOf('day'))}
|
|
122
|
+
preview={preview}
|
|
123
|
+
onClick={onClick}
|
|
124
|
+
onMouseEnter={onMouseEnter}
|
|
125
|
+
selectable={isSelectable}
|
|
126
|
+
currentMonth={isCurrentMonth}
|
|
127
|
+
showOutsideDays={showOutsideDays}
|
|
128
|
+
selection={selection}
|
|
129
|
+
>
|
|
130
|
+
{isPreviousMonth && daysInPreviousMonth - (dayOfWeek - n - 1)}
|
|
131
|
+
{isCurrentMonth && n - dayOfWeek + 1}
|
|
132
|
+
{isNextMonth && n - dayOfWeek - daysInMonth + 1}
|
|
133
|
+
</DatePickerDayView>
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export default DatePickerDay;
|