@arbor-education/design-system.components 4.3.0 → 4.5.0
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/.storybook/preview.ts +10 -0
- package/CHANGELOG.md +16 -0
- package/dist/components/datePicker/DatePicker.stories.d.ts +5 -0
- package/dist/components/datePicker/DatePicker.stories.d.ts.map +1 -1
- package/dist/components/datePicker/DatePicker.stories.js +211 -2
- package/dist/components/datePicker/DatePicker.stories.js.map +1 -1
- package/dist/components/datePicker/DatePickerPopover.d.ts +11 -0
- package/dist/components/datePicker/DatePickerPopover.d.ts.map +1 -0
- package/dist/components/datePicker/DatePickerPopover.js +33 -0
- package/dist/components/datePicker/DatePickerPopover.js.map +1 -0
- package/dist/components/datePicker/DatePickerPopover.test.d.ts +2 -0
- package/dist/components/datePicker/DatePickerPopover.test.d.ts.map +1 -0
- package/dist/components/datePicker/DatePickerPopover.test.js +101 -0
- package/dist/components/datePicker/DatePickerPopover.test.js.map +1 -0
- package/dist/components/slideover/Slideover.d.ts +7 -2
- package/dist/components/slideover/Slideover.d.ts.map +1 -1
- package/dist/components/slideover/Slideover.js +31 -4
- package/dist/components/slideover/Slideover.js.map +1 -1
- package/dist/components/slideover/Slideover.test.js +116 -2
- package/dist/components/slideover/Slideover.test.js.map +1 -1
- package/dist/components/slideoverManager/SlideoverManager.stories.d.ts +3 -0
- package/dist/components/slideoverManager/SlideoverManager.stories.d.ts.map +1 -1
- package/dist/components/slideoverManager/SlideoverManager.stories.js +74 -4
- package/dist/components/slideoverManager/SlideoverManager.stories.js.map +1 -1
- package/dist/index.css +6 -1
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/SlideoverUtils.d.ts +3 -1
- package/dist/utils/SlideoverUtils.d.ts.map +1 -1
- package/dist/utils/SlideoverUtils.js.map +1 -1
- package/package.json +1 -1
- package/src/components/datePicker/DatePicker.stories.tsx +293 -0
- package/src/components/datePicker/DatePickerPopover.test.tsx +144 -0
- package/src/components/datePicker/DatePickerPopover.tsx +72 -0
- package/src/components/slideover/Slideover.test.tsx +152 -2
- package/src/components/slideover/Slideover.tsx +56 -9
- package/src/components/slideover/slideover.scss +8 -1
- package/src/components/slideoverManager/SlideoverManager.stories.tsx +147 -3
- package/src/docs/Contributing.mdx +2 -0
- package/src/index.ts +1 -0
- package/src/utils/SlideoverUtils.ts +4 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { SlideoverProps } from '../components/slideover/Slideover.js';
|
|
2
|
+
type ManagedSlideoverProps = Omit<SlideoverProps, 'isClosing' | 'onCloseTransitionEnd'>;
|
|
2
3
|
export declare const SlideoverUtils: {
|
|
3
|
-
addSlideover: (slideoverConfig:
|
|
4
|
+
addSlideover: (slideoverConfig: ManagedSlideoverProps) => void;
|
|
4
5
|
removeSlideover: () => void;
|
|
5
6
|
removeAllSlideovers: () => void;
|
|
6
7
|
};
|
|
8
|
+
export {};
|
|
7
9
|
//# sourceMappingURL=SlideoverUtils.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SlideoverUtils.d.ts","sourceRoot":"","sources":["../../src/utils/SlideoverUtils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"SlideoverUtils.d.ts","sourceRoot":"","sources":["../../src/utils/SlideoverUtils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAKrE,KAAK,qBAAqB,GAAG,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,sBAAsB,CAAC,CAAC;AAExF,eAAO,MAAM,cAAc;oCACO,qBAAqB;;;CAGtD,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SlideoverUtils.js","sourceRoot":"","sources":["../../src/utils/SlideoverUtils.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,MAAM,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"SlideoverUtils.js","sourceRoot":"","sources":["../../src/utils/SlideoverUtils.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,MAAM,MAAM,aAAa,CAAC;AAKjC,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,YAAY,EAAE,CAAC,eAAsC,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,aAAa,EAAE,eAAe,CAAC;IAClH,eAAe,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,gBAAgB,CAAC;IACjE,mBAAmB,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,qBAAqB,CAAC;CAC3E,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
3
|
import {
|
|
4
|
+
ArgTypes,
|
|
4
5
|
Controls,
|
|
5
6
|
Heading as DocHeading,
|
|
6
7
|
Markdown,
|
|
@@ -9,8 +10,13 @@ import {
|
|
|
9
10
|
Subtitle,
|
|
10
11
|
Title,
|
|
11
12
|
} from '@storybook/addon-docs/blocks';
|
|
13
|
+
import { fn } from 'storybook/test';
|
|
14
|
+
import { format } from 'date-fns';
|
|
15
|
+
import { Button } from 'Components/button/Button';
|
|
12
16
|
import { FormField } from 'Components/formField/FormField';
|
|
17
|
+
import { Icon } from 'Components/icon/Icon';
|
|
13
18
|
import { DatePicker } from './DatePicker.js';
|
|
19
|
+
import { DatePickerPopover } from './DatePickerPopover.js';
|
|
14
20
|
|
|
15
21
|
// ---------------------------------------------------------------------------
|
|
16
22
|
// Docs page content
|
|
@@ -104,6 +110,44 @@ const DEVELOPER_NOTES = [
|
|
|
104
110
|
'| `DatePicker.Props` | Full props interface |',
|
|
105
111
|
].join('\n');
|
|
106
112
|
|
|
113
|
+
const POPOVER_NOTES = [
|
|
114
|
+
'`DatePickerPopover` is the calendar on its own: no date input, no icon button. You supply the',
|
|
115
|
+
'trigger (usually a `Button`) and the component opens the same calendar popover as `DatePicker`.',
|
|
116
|
+
'Use it when the date is shown elsewhere in your UI and you only need a way to pick or jump to a day,',
|
|
117
|
+
'for example a "jump to day" icon in a calendar header. For a form field where the user types or',
|
|
118
|
+
'sees the chosen date, use `DatePicker` instead.',
|
|
119
|
+
'',
|
|
120
|
+
'```tsx',
|
|
121
|
+
"import { Button, DatePickerPopover, Icon } from '@arbor-education/design-system.components';",
|
|
122
|
+
'',
|
|
123
|
+
'<DatePickerPopover',
|
|
124
|
+
' value={date}',
|
|
125
|
+
' onChange={setDate}',
|
|
126
|
+
' trigger={(',
|
|
127
|
+
' <Button variant="tertiary">',
|
|
128
|
+
' <Icon name="date" screenReaderText="Jump to day" />',
|
|
129
|
+
' </Button>',
|
|
130
|
+
' )}',
|
|
131
|
+
'/>',
|
|
132
|
+
'```',
|
|
133
|
+
'',
|
|
134
|
+
'**Give the trigger an accessible name.** `DatePickerPopover` does not add one for you. A text',
|
|
135
|
+
'button is named by its text. An icon-only button needs `screenReaderText` on the `Icon` (or an',
|
|
136
|
+
'`aria-label` on the `Button`), otherwise screen reader users hear an unlabelled button.',
|
|
137
|
+
'',
|
|
138
|
+
'**The trigger must forward its ref and spread props onto a DOM node.** The design system `Button`',
|
|
139
|
+
'does. A custom component that does not will break popover anchoring and focus return to the trigger.',
|
|
140
|
+
'',
|
|
141
|
+
'**You own the value.** `onChange` receives a `Date`; store it and pass it back as `value`. If you',
|
|
142
|
+
'omit `value`, the calendar opens on the current month.',
|
|
143
|
+
'',
|
|
144
|
+
'**Time of day is kept.** Picking a day keeps the hours and minutes of the `value` you passed in.',
|
|
145
|
+
'Picking **Today** does the same.',
|
|
146
|
+
'',
|
|
147
|
+
'**Keyboard and focus.** Focus moves into the calendar when it opens. Selecting a day, choosing',
|
|
148
|
+
'**Today**, or pressing **Escape** closes the calendar and returns focus to your trigger.',
|
|
149
|
+
].join('\n');
|
|
150
|
+
|
|
107
151
|
const RELATED_COMPONENTS = [
|
|
108
152
|
'## Related components',
|
|
109
153
|
'',
|
|
@@ -130,6 +174,9 @@ function DatePickerDocsPage() {
|
|
|
130
174
|
<Markdown>{USAGE_GUIDANCE}</Markdown>
|
|
131
175
|
<DocHeading>Developer notes</DocHeading>
|
|
132
176
|
<Markdown>{DEVELOPER_NOTES}</Markdown>
|
|
177
|
+
<DocHeading>DatePickerPopover</DocHeading>
|
|
178
|
+
<Markdown>{POPOVER_NOTES}</Markdown>
|
|
179
|
+
<ArgTypes of={DatePickerPopover} />
|
|
133
180
|
<DocHeading>Examples</DocHeading>
|
|
134
181
|
<Stories title="" />
|
|
135
182
|
<Markdown>{RELATED_COMPONENTS}</Markdown>
|
|
@@ -820,3 +867,249 @@ export default ParentEveningForm;
|
|
|
820
867
|
'linked via `aria-describedby` automatically. Once a date is chosen, the error clears.',
|
|
821
868
|
].join(' '),
|
|
822
869
|
);
|
|
870
|
+
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
// DatePickerPopover stories
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
|
|
875
|
+
type PopoverStory = StoryObj<typeof DatePickerPopover>;
|
|
876
|
+
|
|
877
|
+
const withPopoverDescription = (story: PopoverStory, description: string): PopoverStory => ({
|
|
878
|
+
...story,
|
|
879
|
+
parameters: {
|
|
880
|
+
...story.parameters,
|
|
881
|
+
docs: {
|
|
882
|
+
...story.parameters?.docs,
|
|
883
|
+
description: {
|
|
884
|
+
story: description,
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
},
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// Fixed date so the play functions are deterministic (15 June 2025, 14:30)
|
|
891
|
+
const POPOVER_INITIAL_DATE = new Date(2025, 5, 15, 14, 30);
|
|
892
|
+
|
|
893
|
+
const formatLongDate = (date: Date) => date.toLocaleDateString('en-GB', {
|
|
894
|
+
weekday: 'long',
|
|
895
|
+
day: 'numeric',
|
|
896
|
+
month: 'long',
|
|
897
|
+
year: 'numeric',
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
type PopoverTemplateProps = {
|
|
901
|
+
onChange?: (newDate: Date) => void;
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const PopoverTextTriggerTemplate = ({ onChange }: PopoverTemplateProps) => {
|
|
905
|
+
const [date, setDate] = useState(POPOVER_INITIAL_DATE);
|
|
906
|
+
|
|
907
|
+
const handleChange = (newDate: Date) => {
|
|
908
|
+
setDate(newDate);
|
|
909
|
+
onChange?.(newDate);
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
return (
|
|
913
|
+
<div>
|
|
914
|
+
<DatePickerPopover
|
|
915
|
+
value={date}
|
|
916
|
+
onChange={handleChange}
|
|
917
|
+
trigger={<Button variant="secondary">Choose date</Button>}
|
|
918
|
+
/>
|
|
919
|
+
<p className="ds-text">
|
|
920
|
+
Selected:
|
|
921
|
+
{' '}
|
|
922
|
+
<code>{`${date.toLocaleDateString('en-GB')} ${format(date, 'HH:mm')}`}</code>
|
|
923
|
+
</p>
|
|
924
|
+
</div>
|
|
925
|
+
);
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const PopoverIconTriggerTemplate = ({ onChange }: PopoverTemplateProps) => {
|
|
929
|
+
const [date, setDate] = useState(POPOVER_INITIAL_DATE);
|
|
930
|
+
|
|
931
|
+
const handleChange = (newDate: Date) => {
|
|
932
|
+
setDate(newDate);
|
|
933
|
+
onChange?.(newDate);
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
return (
|
|
937
|
+
<div>
|
|
938
|
+
<DatePickerPopover
|
|
939
|
+
value={date}
|
|
940
|
+
onChange={handleChange}
|
|
941
|
+
trigger={(
|
|
942
|
+
<Button variant="tertiary">
|
|
943
|
+
<Icon name="date" screenReaderText="Choose date" />
|
|
944
|
+
</Button>
|
|
945
|
+
)}
|
|
946
|
+
/>
|
|
947
|
+
<p className="ds-text">
|
|
948
|
+
Selected:
|
|
949
|
+
{' '}
|
|
950
|
+
<code>{date.toLocaleDateString('en-GB')}</code>
|
|
951
|
+
</p>
|
|
952
|
+
</div>
|
|
953
|
+
);
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
const PopoverCalendarHeaderTemplate = ({ onChange }: PopoverTemplateProps) => {
|
|
957
|
+
const [viewedDate, setViewedDate] = useState(POPOVER_INITIAL_DATE);
|
|
958
|
+
|
|
959
|
+
const handleChange = (newDate: Date) => {
|
|
960
|
+
setViewedDate(newDate);
|
|
961
|
+
onChange?.(newDate);
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
return (
|
|
965
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-small)' }}>
|
|
966
|
+
<DatePickerPopover
|
|
967
|
+
value={viewedDate}
|
|
968
|
+
onChange={handleChange}
|
|
969
|
+
trigger={(
|
|
970
|
+
<Button variant="tertiary" borderless>
|
|
971
|
+
<Icon name="date" screenReaderText="Jump to day" />
|
|
972
|
+
</Button>
|
|
973
|
+
)}
|
|
974
|
+
/>
|
|
975
|
+
<p className="ds-text">
|
|
976
|
+
<time dateTime={format(viewedDate, 'yyyy-MM-dd')}>{formatLongDate(viewedDate)}</time>
|
|
977
|
+
</p>
|
|
978
|
+
</div>
|
|
979
|
+
);
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
export const PopoverWithTextTrigger: PopoverStory = withPopoverDescription(
|
|
983
|
+
{
|
|
984
|
+
args: { onChange: fn() },
|
|
985
|
+
render: args => <PopoverTextTriggerTemplate onChange={args.onChange} />,
|
|
986
|
+
parameters: {
|
|
987
|
+
controls: { disable: true },
|
|
988
|
+
docs: {
|
|
989
|
+
source: {
|
|
990
|
+
language: 'tsx',
|
|
991
|
+
code: `
|
|
992
|
+
import { useState } from 'react';
|
|
993
|
+
import { Button, DatePickerPopover } from '@arbor-education/design-system.components';
|
|
994
|
+
|
|
995
|
+
function ChooseDateButton() {
|
|
996
|
+
const [date, setDate] = useState(new Date(2025, 5, 15, 14, 30));
|
|
997
|
+
|
|
998
|
+
return (
|
|
999
|
+
<DatePickerPopover
|
|
1000
|
+
value={date}
|
|
1001
|
+
onChange={setDate}
|
|
1002
|
+
// A text button is named by its text content
|
|
1003
|
+
trigger={<Button variant="secondary">Choose date</Button>}
|
|
1004
|
+
/>
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
export default ChooseDateButton;
|
|
1008
|
+
`.trim(),
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
[
|
|
1014
|
+
'The simplest setup: a text `Button` as the trigger, with the date held in your own state.',
|
|
1015
|
+
'The calendar opens on the month of `value`. Selecting a day (or **Today**) calls `onChange` with a',
|
|
1016
|
+
'`Date`, keeps the time of day from `value`, closes the calendar and returns focus to the button.',
|
|
1017
|
+
'The button is named by its text, so no extra accessibility props are needed.',
|
|
1018
|
+
].join(' '),
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
export const PopoverWithIconTrigger: PopoverStory = withPopoverDescription(
|
|
1022
|
+
{
|
|
1023
|
+
args: { onChange: fn() },
|
|
1024
|
+
render: args => <PopoverIconTriggerTemplate onChange={args.onChange} />,
|
|
1025
|
+
parameters: {
|
|
1026
|
+
controls: { disable: true },
|
|
1027
|
+
docs: {
|
|
1028
|
+
source: {
|
|
1029
|
+
language: 'tsx',
|
|
1030
|
+
code: `
|
|
1031
|
+
import { useState } from 'react';
|
|
1032
|
+
import { Button, DatePickerPopover, Icon } from '@arbor-education/design-system.components';
|
|
1033
|
+
|
|
1034
|
+
function ChooseDateIconButton() {
|
|
1035
|
+
const [date, setDate] = useState(new Date(2025, 5, 15, 14, 30));
|
|
1036
|
+
|
|
1037
|
+
return (
|
|
1038
|
+
<DatePickerPopover
|
|
1039
|
+
value={date}
|
|
1040
|
+
onChange={setDate}
|
|
1041
|
+
trigger={(
|
|
1042
|
+
<Button variant="tertiary">
|
|
1043
|
+
{/* screenReaderText is the button's accessible name */}
|
|
1044
|
+
<Icon name="date" screenReaderText="Choose date" />
|
|
1045
|
+
</Button>
|
|
1046
|
+
)}
|
|
1047
|
+
/>
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
export default ChooseDateIconButton;
|
|
1051
|
+
`.trim(),
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
[
|
|
1057
|
+
'An icon-only trigger. The `Icon` is hidden from assistive technology, so **you must give the button',
|
|
1058
|
+
'an accessible name**: use `screenReaderText` on the `Icon` (shown here) or `aria-label` on the `Button`.',
|
|
1059
|
+
'Keyboard users can open the calendar with Enter or Space, move between days with the arrow keys,',
|
|
1060
|
+
'and press **Escape** to close it without choosing a date. Focus returns to the icon button.',
|
|
1061
|
+
].join(' '),
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
export const PopoverCalendarHeaderJumpToDay: PopoverStory = withPopoverDescription(
|
|
1065
|
+
{
|
|
1066
|
+
args: { onChange: fn() },
|
|
1067
|
+
render: args => <PopoverCalendarHeaderTemplate onChange={args.onChange} />,
|
|
1068
|
+
parameters: {
|
|
1069
|
+
controls: { disable: true },
|
|
1070
|
+
docs: {
|
|
1071
|
+
source: {
|
|
1072
|
+
language: 'tsx',
|
|
1073
|
+
code: `
|
|
1074
|
+
import { useState } from 'react';
|
|
1075
|
+
import { format } from 'date-fns';
|
|
1076
|
+
import { Button, DatePickerPopover, Icon } from '@arbor-education/design-system.components';
|
|
1077
|
+
|
|
1078
|
+
function CalendarHeader() {
|
|
1079
|
+
const [viewedDate, setViewedDate] = useState(new Date(2025, 5, 15, 14, 30));
|
|
1080
|
+
|
|
1081
|
+
return (
|
|
1082
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-small)' }}>
|
|
1083
|
+
<DatePickerPopover
|
|
1084
|
+
value={viewedDate}
|
|
1085
|
+
onChange={setViewedDate}
|
|
1086
|
+
trigger={(
|
|
1087
|
+
<Button variant="tertiary" borderless>
|
|
1088
|
+
<Icon name="date" screenReaderText="Jump to day" />
|
|
1089
|
+
</Button>
|
|
1090
|
+
)}
|
|
1091
|
+
/>
|
|
1092
|
+
<time dateTime={format(viewedDate, 'yyyy-MM-dd')}>
|
|
1093
|
+
{viewedDate.toLocaleDateString('en-GB', {
|
|
1094
|
+
weekday: 'long',
|
|
1095
|
+
day: 'numeric',
|
|
1096
|
+
month: 'long',
|
|
1097
|
+
year: 'numeric',
|
|
1098
|
+
})}
|
|
1099
|
+
</time>
|
|
1100
|
+
</div>
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
export default CalendarHeader;
|
|
1104
|
+
`.trim(),
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
[
|
|
1110
|
+
'The motivating use case: a "jump to day" icon button in a calendar header, with the currently viewed',
|
|
1111
|
+
'date shown beside it. The date lives in your state, so the heading updates as soon as a day is picked',
|
|
1112
|
+
'and the same value is passed back in as `value` the next time the calendar opens.',
|
|
1113
|
+
'There is no date input, so the header stays compact and the selection is communicated by the text next to the button.',
|
|
1114
|
+
].join(' '),
|
|
1115
|
+
);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { render, screen, within } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import '@testing-library/jest-dom/vitest';
|
|
5
|
+
import { Button } from 'Components/button/Button';
|
|
6
|
+
import { DatePickerPopover } from './DatePickerPopover.js';
|
|
7
|
+
|
|
8
|
+
const renderPopover = (props: Partial<React.ComponentProps<typeof DatePickerPopover>> = {}) => render(
|
|
9
|
+
<DatePickerPopover
|
|
10
|
+
trigger={<Button>Jump to date</Button>}
|
|
11
|
+
value={new Date(2025, 5, 15, 14, 30)}
|
|
12
|
+
{...props}
|
|
13
|
+
/>,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const openCalendar = () => userEvent.click(screen.getByRole('button', { name: 'Jump to date' }));
|
|
17
|
+
|
|
18
|
+
describe('DatePickerPopover', () => {
|
|
19
|
+
test('renders the consumer trigger and no date input', () => {
|
|
20
|
+
const { container } = renderPopover();
|
|
21
|
+
expect(screen.getByRole('button', { name: 'Jump to date' })).toBeInTheDocument();
|
|
22
|
+
expect(container.querySelector('input')).not.toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('calendar is hidden until the trigger is activated', async () => {
|
|
26
|
+
renderPopover();
|
|
27
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
28
|
+
|
|
29
|
+
await openCalendar();
|
|
30
|
+
|
|
31
|
+
expect(screen.getByRole('application')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('opens on the month of the supplied value', async () => {
|
|
35
|
+
renderPopover();
|
|
36
|
+
await openCalendar();
|
|
37
|
+
|
|
38
|
+
expect(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ })).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('selecting a day calls onChange with a Date and closes the calendar', async () => {
|
|
42
|
+
const onChange = vi.fn();
|
|
43
|
+
renderPopover({ onChange });
|
|
44
|
+
await openCalendar();
|
|
45
|
+
|
|
46
|
+
await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
47
|
+
|
|
48
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
49
|
+
const selected = onChange.mock.lastCall?.[0] as Date;
|
|
50
|
+
expect(selected).toBeInstanceOf(Date);
|
|
51
|
+
expect([selected.getFullYear(), selected.getMonth(), selected.getDate()]).toEqual([2025, 5, 20]);
|
|
52
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('selecting a day preserves the time of the supplied value', async () => {
|
|
56
|
+
const onChange = vi.fn();
|
|
57
|
+
renderPopover({ onChange });
|
|
58
|
+
await openCalendar();
|
|
59
|
+
|
|
60
|
+
await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
61
|
+
|
|
62
|
+
const selected = onChange.mock.lastCall?.[0] as Date;
|
|
63
|
+
expect([selected.getHours(), selected.getMinutes()]).toEqual([14, 30]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('clicking the already selected day still calls onChange and closes the calendar', async () => {
|
|
67
|
+
const onChange = vi.fn();
|
|
68
|
+
renderPopover({ onChange });
|
|
69
|
+
await openCalendar();
|
|
70
|
+
|
|
71
|
+
await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 15/ }));
|
|
72
|
+
|
|
73
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(onChange.mock.lastCall?.[0]).toBeInstanceOf(Date);
|
|
75
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('clicking Today calls onChange with today and closes the calendar', async () => {
|
|
79
|
+
const onChange = vi.fn();
|
|
80
|
+
renderPopover({ onChange });
|
|
81
|
+
await openCalendar();
|
|
82
|
+
|
|
83
|
+
await userEvent.click(screen.getByRole('button', { name: 'Today' }));
|
|
84
|
+
|
|
85
|
+
const today = new Date();
|
|
86
|
+
const selected = onChange.mock.lastCall?.[0] as Date;
|
|
87
|
+
expect([selected.getFullYear(), selected.getMonth(), selected.getDate()])
|
|
88
|
+
.toEqual([today.getFullYear(), today.getMonth(), today.getDate()]);
|
|
89
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('works without a value, opening on the current month', async () => {
|
|
93
|
+
const onChange = vi.fn();
|
|
94
|
+
renderPopover({ value: undefined, onChange });
|
|
95
|
+
await openCalendar();
|
|
96
|
+
|
|
97
|
+
await userEvent.click(screen.getByRole('button', { name: 'Today' }));
|
|
98
|
+
|
|
99
|
+
expect(onChange).toHaveBeenCalledWith(expect.any(Date));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('focus moves into the calendar on open', async () => {
|
|
103
|
+
renderPopover();
|
|
104
|
+
await openCalendar();
|
|
105
|
+
|
|
106
|
+
expect(screen.getByRole('application')).toContainElement(document.activeElement as HTMLElement);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('Escape closes the calendar and returns focus to the trigger', async () => {
|
|
110
|
+
renderPopover();
|
|
111
|
+
await openCalendar();
|
|
112
|
+
|
|
113
|
+
await userEvent.keyboard('{Escape}');
|
|
114
|
+
|
|
115
|
+
expect(screen.queryByRole('application')).not.toBeInTheDocument();
|
|
116
|
+
expect(screen.getByRole('button', { name: 'Jump to date' })).toHaveFocus();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('selecting a day returns focus to the trigger', async () => {
|
|
120
|
+
renderPopover();
|
|
121
|
+
await openCalendar();
|
|
122
|
+
|
|
123
|
+
await userEvent.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
124
|
+
|
|
125
|
+
expect(screen.getByRole('button', { name: 'Jump to date' })).toHaveFocus();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('reopening shows the month of the latest value after the user browsed away', async () => {
|
|
129
|
+
const { rerender } = renderPopover();
|
|
130
|
+
await openCalendar();
|
|
131
|
+
await userEvent.click(screen.getByRole('button', { name: 'Go to the Next Month' }));
|
|
132
|
+
await userEvent.keyboard('{Escape}');
|
|
133
|
+
|
|
134
|
+
rerender(
|
|
135
|
+
<DatePickerPopover
|
|
136
|
+
trigger={<Button>Jump to date</Button>}
|
|
137
|
+
value={new Date(2025, 8, 3)}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
await openCalendar();
|
|
141
|
+
|
|
142
|
+
expect(within(screen.getByRole('application')).getByRole('button', { name: /September 10/ })).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Popover } from 'radix-ui';
|
|
2
|
+
import { useContext, useState, type ReactElement } from 'react';
|
|
3
|
+
import { PopupParentContext } from 'Utils/popupParent/PopupParentContext';
|
|
4
|
+
import { formatTimeInputValue, mergeDateAndTime } from './dateInputUtils.js';
|
|
5
|
+
import { DatePickerCalendar } from './DatePickerCalendar.js';
|
|
6
|
+
import { DatePickerCalendarHeader } from './DatePickerCalendarHeader.js';
|
|
7
|
+
|
|
8
|
+
const TIME_PRESERVATION_GRANULARITY = 'minute' as const;
|
|
9
|
+
|
|
10
|
+
export type DatePickerPopoverProps = {
|
|
11
|
+
trigger: ReactElement;
|
|
12
|
+
value?: Date;
|
|
13
|
+
onChange?: (newDate: Date) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const DatePickerPopover = (props: DatePickerPopoverProps) => {
|
|
17
|
+
const { trigger, value, onChange } = props;
|
|
18
|
+
|
|
19
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
20
|
+
const [month, setMonth] = useState(value ?? new Date());
|
|
21
|
+
const popupParentRef = useContext(PopupParentContext);
|
|
22
|
+
|
|
23
|
+
const handleOpenChange = (open: boolean) => {
|
|
24
|
+
if (open) {
|
|
25
|
+
setMonth(value ?? new Date());
|
|
26
|
+
}
|
|
27
|
+
setIsOpen(open);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const selectDay = (day: Date) => {
|
|
31
|
+
const timeSource = value ?? day;
|
|
32
|
+
onChange?.(
|
|
33
|
+
mergeDateAndTime(
|
|
34
|
+
day,
|
|
35
|
+
formatTimeInputValue(timeSource, TIME_PRESERVATION_GRANULARITY),
|
|
36
|
+
TIME_PRESERVATION_GRANULARITY,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
setIsOpen(false);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const onDayPickerSelect = (day?: Date) => {
|
|
43
|
+
const selectedDay = day ?? value;
|
|
44
|
+
if (selectedDay) {
|
|
45
|
+
selectDay(selectedDay);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
|
|
51
|
+
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
|
|
52
|
+
<Popover.Portal container={popupParentRef.current}>
|
|
53
|
+
<Popover.Content align="end" sideOffset={5} onOpenAutoFocus={e => e.preventDefault()}>
|
|
54
|
+
<div className="ds-date-picker__popup">
|
|
55
|
+
<DatePickerCalendarHeader month={month} onMonthChange={setMonth} />
|
|
56
|
+
<DatePickerCalendar
|
|
57
|
+
month={month}
|
|
58
|
+
setMonth={setMonth}
|
|
59
|
+
selectedDate={value}
|
|
60
|
+
onDayPickerSelect={onDayPickerSelect}
|
|
61
|
+
handleTodayClick={() => selectDay(new Date())}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
</Popover.Content>
|
|
65
|
+
</Popover.Portal>
|
|
66
|
+
</Popover.Root>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export namespace DatePickerPopover {
|
|
71
|
+
export type Props = DatePickerPopoverProps;
|
|
72
|
+
}
|