@genspectrum/dashboard-components 0.3.2 → 0.4.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.
Files changed (27) hide show
  1. package/custom-elements.json +45 -26
  2. package/dist/dashboard-components.js +340 -186
  3. package/dist/dashboard-components.js.map +1 -1
  4. package/dist/genspectrum-components.d.ts +17 -8
  5. package/dist/style.css +58 -50
  6. package/package.json +2 -1
  7. package/src/preact/aggregatedData/aggregate.tsx +1 -1
  8. package/src/preact/components/info.stories.tsx +8 -8
  9. package/src/preact/components/info.tsx +113 -20
  10. package/src/preact/dateRangeSelector/computeInitialValues.spec.ts +99 -0
  11. package/src/preact/dateRangeSelector/computeInitialValues.ts +73 -0
  12. package/src/preact/dateRangeSelector/date-range-selector.stories.tsx +93 -4
  13. package/src/preact/dateRangeSelector/date-range-selector.tsx +27 -88
  14. package/src/preact/dateRangeSelector/selectableOptions.ts +79 -0
  15. package/src/preact/locationFilter/location-filter.tsx +1 -1
  16. package/src/preact/mutationComparison/mutation-comparison.tsx +1 -1
  17. package/src/preact/mutationFilter/mutation-filter.stories.tsx +3 -6
  18. package/src/preact/mutationFilter/mutation-filter.tsx +48 -54
  19. package/src/preact/mutations/mutations.tsx +1 -1
  20. package/src/preact/prevalenceOverTime/prevalence-over-time.tsx +2 -2
  21. package/src/preact/relativeGrowthAdvantage/relative-growth-advantage.tsx +1 -1
  22. package/src/web-components/input/gs-date-range-selector.stories.ts +11 -5
  23. package/src/web-components/input/gs-date-range-selector.tsx +22 -5
  24. package/src/web-components/input/gs-location-filter.stories.ts +6 -7
  25. package/src/web-components/input/gs-location-filter.tsx +3 -2
  26. package/src/web-components/input/gs-mutation-filter.stories.ts +1 -8
  27. package/src/web-components/input/gs-mutation-filter.tsx +1 -9
@@ -139,8 +139,22 @@ export declare class DateRangeSelectorComponent extends PreactLitAdapter {
139
139
  * Must be a valid label from the preset labels or a `label` given in the `customSelectOptions`.
140
140
  *
141
141
  * If the value is invalid, the component will default to `'last6Months'`.
142
+ *
143
+ * It will be overwritten if `initialDateFrom` or `initialDateTo` is set.
142
144
  */
143
145
  initialValue: 'custom' | 'allTimes' | 'last2Weeks' | 'lastMonth' | 'last2Months' | 'last3Months' | 'last6Months' | string;
146
+ /**
147
+ * A date string in the format `YYYY-MM-DD`.
148
+ * If set, the date range selector will be initialized with the given date (overwriting `initialValue` to `custom`).
149
+ * If `initialDateTo` is set, but this is unset, it will default to `earliestDate`.
150
+ */
151
+ initialDateFrom: string;
152
+ /**
153
+ * A date string in the format `YYYY-MM-DD`.
154
+ * If set, the date range selector will be initialized with the given date (overwriting `initialValue` to `custom`).
155
+ * If `initialDateFrom` is set, but this is unset, it will default to the current date.
156
+ */
157
+ initialDateTo: string;
144
158
  /**
145
159
  * The width of the component.
146
160
  *
@@ -173,13 +187,14 @@ declare type LapisFilter = Record<string, string | number | null | boolean>;
173
187
  * @fires {CustomEvent<Record<string, string>>} gs-location-changed
174
188
  * Fired when a value from the datalist is selected or when a valid value is typed into the field.
175
189
  * The `details` of this event contain an object with all `fields` as keys
176
- * and the corresponding values as values, if they are not `undefined`.
190
+ * and the corresponding values as values, even if they are `undefined`.
177
191
  * Example:
178
192
  * ```
179
193
  * {
180
194
  * continent: "Asia",
181
195
  * country: "China",
182
- * city: "Beijing"
196
+ * city: "Beijing",
197
+ * district: undefined,
183
198
  * }
184
199
  * ```
185
200
  */
@@ -347,12 +362,6 @@ export declare class MutationFilterComponent extends PreactLitAdapter {
347
362
  * Visit https://genspectrum.github.io/dashboards/?path=/docs/components-size-of-components--docs for more information.
348
363
  */
349
364
  width: string;
350
- /**
351
- * The height of the component.
352
- *
353
- * Visit https://genspectrum.github.io/dashboards/?path=/docs/components-size-of-components--docs for more information.
354
- */
355
- height: string;
356
365
  render(): JSX_2.Element;
357
366
  }
358
367
 
package/dist/style.css CHANGED
@@ -2853,24 +2853,30 @@ input.tab:checked + .tab-content,
2853
2853
  .relative {
2854
2854
  position: relative;
2855
2855
  }
2856
+ .-right-3 {
2857
+ right: -0.75rem;
2858
+ }
2859
+ .-top-3 {
2860
+ top: -0.75rem;
2861
+ }
2856
2862
  .right-2 {
2857
2863
  right: 0.5rem;
2858
2864
  }
2859
- .right-6 {
2860
- right: 1.5rem;
2861
- }
2862
2865
  .top-2 {
2863
2866
  top: 0.5rem;
2864
2867
  }
2865
- .top-8 {
2866
- top: 2rem;
2867
- }
2868
- .z-50 {
2869
- z-index: 50;
2868
+ .z-10 {
2869
+ z-index: 10;
2870
2870
  }
2871
2871
  .z-\[1\] {
2872
2872
  z-index: 1;
2873
2873
  }
2874
+ .float-right {
2875
+ float: right;
2876
+ }
2877
+ .m-1 {
2878
+ margin: 0.25rem;
2879
+ }
2874
2880
  .m-2 {
2875
2881
  margin: 0.5rem;
2876
2882
  }
@@ -2886,24 +2892,21 @@ input.tab:checked + .tab-content,
2886
2892
  margin-top: 1rem;
2887
2893
  margin-bottom: 1rem;
2888
2894
  }
2889
- .mb-1 {
2890
- margin-bottom: 0.25rem;
2891
- }
2892
2895
  .mb-2 {
2893
2896
  margin-bottom: 0.5rem;
2894
2897
  }
2895
- .me-1 {
2896
- margin-inline-end: 0.25rem;
2897
- }
2898
2898
  .ml-2 {
2899
2899
  margin-left: 0.5rem;
2900
2900
  }
2901
- .mt-2 {
2902
- margin-top: 0.5rem;
2901
+ .mr-2 {
2902
+ margin-right: 0.5rem;
2903
2903
  }
2904
2904
  .mt-4 {
2905
2905
  margin-top: 1rem;
2906
2906
  }
2907
+ .inline-block {
2908
+ display: inline-block;
2909
+ }
2907
2910
  .inline {
2908
2911
  display: inline;
2909
2912
  }
@@ -2937,13 +2940,6 @@ input.tab:checked + .tab-content,
2937
2940
  .w-full {
2938
2941
  width: 100%;
2939
2942
  }
2940
- .min-w-0 {
2941
- min-width: 0px;
2942
- }
2943
- .min-w-max {
2944
- min-width: -moz-max-content;
2945
- min-width: max-content;
2946
- }
2947
2943
  .max-w-screen-lg {
2948
2944
  max-width: 1024px;
2949
2945
  }
@@ -2962,32 +2958,23 @@ input.tab:checked + .tab-content,
2962
2958
  .flex-wrap {
2963
2959
  flex-wrap: wrap;
2964
2960
  }
2965
- .flex-nowrap {
2966
- flex-wrap: nowrap;
2967
- }
2968
2961
  .items-center {
2969
2962
  align-items: center;
2970
2963
  }
2971
- .justify-end {
2972
- justify-content: flex-end;
2973
- }
2974
2964
  .justify-center {
2975
2965
  justify-content: center;
2976
2966
  }
2977
2967
  .justify-between {
2978
2968
  justify-content: space-between;
2979
2969
  }
2980
- .gap-1 {
2981
- gap: 0.25rem;
2982
- }
2983
2970
  .gap-2 {
2984
2971
  gap: 0.5rem;
2985
2972
  }
2986
2973
  .overflow-auto {
2987
2974
  overflow: auto;
2988
2975
  }
2989
- .overflow-scroll {
2990
- overflow: scroll;
2976
+ .overflow-y-auto {
2977
+ overflow-y: auto;
2991
2978
  }
2992
2979
  .whitespace-nowrap {
2993
2980
  white-space: nowrap;
@@ -3004,8 +2991,8 @@ input.tab:checked + .tab-content,
3004
2991
  .rounded-box {
3005
2992
  border-radius: var(--rounded-box, 1rem);
3006
2993
  }
3007
- .rounded-lg {
3008
- border-radius: 0.5rem;
2994
+ .rounded-full {
2995
+ border-radius: 9999px;
3009
2996
  }
3010
2997
  .rounded-md {
3011
2998
  border-radius: 0.375rem;
@@ -3032,9 +3019,11 @@ input.tab:checked + .tab-content,
3032
3019
  .border-b-2 {
3033
3020
  border-bottom-width: 2px;
3034
3021
  }
3035
- .border-black {
3036
- --tw-border-opacity: 1;
3037
- border-color: rgb(0 0 0 / var(--tw-border-opacity));
3022
+ .border-solid {
3023
+ border-style: solid;
3024
+ }
3025
+ .border-none {
3026
+ border-style: none;
3038
3027
  }
3039
3028
  .border-error {
3040
3029
  --tw-border-opacity: 1;
@@ -3052,6 +3041,10 @@ input.tab:checked + .tab-content,
3052
3041
  --tw-border-opacity: 1;
3053
3042
  border-color: rgb(156 163 175 / var(--tw-border-opacity));
3054
3043
  }
3044
+ .border-red-500 {
3045
+ --tw-border-opacity: 1;
3046
+ border-color: rgb(239 68 68 / var(--tw-border-opacity));
3047
+ }
3055
3048
  .bg-base-100 {
3056
3049
  --tw-bg-opacity: 1;
3057
3050
  background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));
@@ -3064,6 +3057,9 @@ input.tab:checked + .tab-content,
3064
3057
  --tw-bg-opacity: 1;
3065
3058
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
3066
3059
  }
3060
+ .p-1 {
3061
+ padding: 0.25rem;
3062
+ }
3067
3063
  .p-2 {
3068
3064
  padding: 0.5rem;
3069
3065
  }
@@ -3071,21 +3067,13 @@ input.tab:checked + .tab-content,
3071
3067
  padding-left: 0.5rem;
3072
3068
  padding-right: 0.5rem;
3073
3069
  }
3074
- .px-2\.5 {
3075
- padding-left: 0.625rem;
3076
- padding-right: 0.625rem;
3077
- }
3078
3070
  .px-4 {
3079
3071
  padding-left: 1rem;
3080
3072
  padding-right: 1rem;
3081
3073
  }
3082
- .py-0 {
3083
- padding-top: 0px;
3084
- padding-bottom: 0px;
3085
- }
3086
- .py-0\.5 {
3087
- padding-top: 0.125rem;
3088
- padding-bottom: 0.125rem;
3074
+ .py-1 {
3075
+ padding-top: 0.25rem;
3076
+ padding-bottom: 0.25rem;
3089
3077
  }
3090
3078
  .py-16 {
3091
3079
  padding-top: 4rem;
@@ -3146,6 +3134,9 @@ input.tab:checked + .tab-content,
3146
3134
  .underline {
3147
3135
  text-decoration-line: underline;
3148
3136
  }
3137
+ .opacity-90 {
3138
+ opacity: 0.9;
3139
+ }
3149
3140
  .shadow {
3150
3141
  --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
3151
3142
  --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
@@ -3171,10 +3162,18 @@ input.tab:checked + .tab-content,
3171
3162
  .duration-150 {
3172
3163
  transition-duration: 150ms;
3173
3164
  }
3165
+ .focus-within\:border-gray-400:focus-within {
3166
+ --tw-border-opacity: 1;
3167
+ border-color: rgb(156 163 175 / var(--tw-border-opacity));
3168
+ }
3174
3169
  .hover\:bg-gray-100:hover {
3175
3170
  --tw-bg-opacity: 1;
3176
3171
  background-color: rgb(243 244 246 / var(--tw-bg-opacity));
3177
3172
  }
3173
+ .hover\:text-blue-700:hover {
3174
+ --tw-text-opacity: 1;
3175
+ color: rgb(29 78 216 / var(--tw-text-opacity));
3176
+ }
3178
3177
  .hover\:text-blue-800:hover {
3179
3178
  --tw-text-opacity: 1;
3180
3179
  color: rgb(30 64 175 / var(--tw-text-opacity));
@@ -3186,6 +3185,15 @@ input.tab:checked + .tab-content,
3186
3185
  .hover\:text-gray-700:hover {
3187
3186
  --tw-text-opacity: 1;
3188
3187
  color: rgb(55 65 81 / var(--tw-text-opacity));
3188
+ }
3189
+ .focus\:outline-none:focus {
3190
+ outline: 2px solid transparent;
3191
+ outline-offset: 2px;
3192
+ }
3193
+ .focus\:ring-0:focus {
3194
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
3195
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);
3196
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
3189
3197
  }.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/*
3190
3198
  /*rtl:begin:ignore*/left:0/*
3191
3199
  /*rtl:end:ignore*/}/*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genspectrum/dashboard-components",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "GenSpectrum web components for building dashboards",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -56,6 +56,7 @@
56
56
  "lit"
57
57
  ],
58
58
  "dependencies": {
59
+ "@floating-ui/dom": "^1.6.5",
59
60
  "@lit/context": "^1.1.1",
60
61
  "@lit/reactive-element": "^2.0.4",
61
62
  "@lit/task": "^1.0.0",
@@ -103,7 +103,7 @@ const Toolbar: FunctionComponent<ToolbarProps> = ({ data }) => {
103
103
  return (
104
104
  <div class='flex flex-row'>
105
105
  <CsvDownloadButton className='mx-1 btn btn-xs' getData={() => data} filename='aggregate.csv' />
106
- <Info>Info for aggregate</Info>
106
+ <Info height={'100px'}>Info for aggregate</Info>
107
107
  </div>
108
108
  );
109
109
  };
@@ -1,5 +1,5 @@
1
1
  import { type Meta, type StoryObj } from '@storybook/preact';
2
- import { expect, fireEvent, waitFor, within } from '@storybook/test';
2
+ import { expect, userEvent, waitFor, within } from '@storybook/test';
3
3
 
4
4
  import Info, { type InfoProps } from './info';
5
5
 
@@ -8,7 +8,7 @@ const meta: Meta<InfoProps> = {
8
8
  component: Info,
9
9
  parameters: { fetchMock: {} },
10
10
  args: {
11
- size: { width: '400px', height: '100px' },
11
+ height: '100px',
12
12
  },
13
13
  };
14
14
 
@@ -28,16 +28,16 @@ export const ShowsInfoOnClick: StoryObj<InfoProps> = {
28
28
  ...InfoStory,
29
29
  play: async ({ canvasElement }) => {
30
30
  const canvas = within(canvasElement);
31
- const loading = canvas.getByRole('button', { name: '?' });
31
+ const openInfo = canvas.getByRole('button', { name: '?' });
32
32
 
33
- await waitFor(() => expect(loading).toBeInTheDocument());
33
+ await waitFor(() => expect(openInfo).toBeInTheDocument());
34
34
 
35
- await fireEvent.click(loading);
35
+ await userEvent.click(openInfo);
36
36
 
37
- await waitFor(() => expect(canvas.getByText(tooltipText, { exact: false })).toBeInTheDocument());
37
+ await waitFor(() => expect(canvas.getByText(tooltipText, { exact: false })).toBeVisible());
38
38
 
39
- await fireEvent.click(canvas.getByRole('button', { name: 'Close' }));
39
+ await userEvent.click(document.body);
40
40
 
41
- await waitFor(() => expect(canvas.queryByText(tooltipText, { exact: false })).not.toBeInTheDocument());
41
+ await waitFor(() => expect(canvas.queryByText(tooltipText, { exact: false })).not.toBeVisible());
42
42
  },
43
43
  };
@@ -1,42 +1,135 @@
1
+ import { autoUpdate, computePosition, offset, shift, size } from '@floating-ui/dom';
1
2
  import { type FunctionComponent } from 'preact';
2
- import { useState } from 'preact/hooks';
3
+ import { useEffect, useRef, useState } from 'preact/hooks';
4
+ import { type MutableRefObject } from 'react';
3
5
 
4
6
  export interface InfoProps {
5
- size?: {
6
- height?: string;
7
- width?: string;
8
- };
7
+ height?: string;
9
8
  }
10
9
 
11
- const Info: FunctionComponent<InfoProps> = ({ children, size }) => {
10
+ const Info: FunctionComponent<InfoProps> = ({ children, height }) => {
12
11
  const [showHelp, setShowHelp] = useState(false);
12
+ const referenceRef = useRef<HTMLButtonElement>(null);
13
+ const floatingRef = useRef<HTMLDivElement>(null);
14
+
15
+ useFloatingUi(referenceRef, floatingRef, height, showHelp);
13
16
 
14
17
  const toggleHelp = () => {
15
18
  setShowHelp(!showHelp);
16
19
  };
17
20
 
21
+ useCloseOnEsc(setShowHelp);
22
+ useCloseOnClickOutside(floatingRef, referenceRef, setShowHelp);
23
+
18
24
  return (
19
- <div className='relative'>
20
- <button className='btn btn-xs' onClick={toggleHelp}>
25
+ <div className='relative z-10'>
26
+ <button type='button' className='btn btn-xs' onClick={toggleHelp} ref={referenceRef}>
21
27
  ?
22
28
  </button>
23
- {showHelp && (
24
- <div
25
- className='absolute top-8 right-6 bg-white p-2 border border-black flex flex-col overflow-auto shadow-lg rounded z-50'
26
- style={size}
29
+ <div
30
+ ref={floatingRef}
31
+ className='bg-white p-2 border border-gray-100 shadow-lg rounded overflow-y-auto opacity-90'
32
+ style={{ position: 'absolute', zIndex: 10, display: showHelp ? '' : 'none' }}
33
+ >
34
+ <div className={'flex flex-col'}>{children}</div>
35
+ <button
36
+ onClick={() => setShowHelp(false)}
37
+ className={'float-right underline text-sm hover:text-blue-700 mr-2'}
27
38
  >
28
- <div className='flex flex-col'>{children}</div>
29
- <div className='flex justify-end'>
30
- <button className='text-sm underline mt-2' onClick={toggleHelp}>
31
- Close
32
- </button>
33
- </div>
34
- </div>
35
- )}
39
+ Close
40
+ </button>
41
+ </div>
36
42
  </div>
37
43
  );
38
44
  };
39
45
 
46
+ function useFloatingUi(
47
+ referenceRef: MutableRefObject<HTMLButtonElement | null>,
48
+ floatingRef: MutableRefObject<HTMLDivElement | null>,
49
+ height: string | undefined,
50
+ showHelp: boolean,
51
+ ) {
52
+ const cleanupRef = useRef<Function | null>(null);
53
+
54
+ useEffect(() => {
55
+ if (!referenceRef.current || !floatingRef.current) {
56
+ return;
57
+ }
58
+
59
+ const { current: reference } = referenceRef;
60
+ const { current: floating } = floatingRef;
61
+
62
+ const update = () => {
63
+ computePosition(reference, floating, {
64
+ middleware: [
65
+ offset(10),
66
+ shift(),
67
+ size({
68
+ apply({}) {
69
+ floating.style.width = '100vw';
70
+ floating.style.height = height ? height : '50vh';
71
+ },
72
+ }),
73
+ ],
74
+ }).then(({ x, y }) => {
75
+ floating.style.left = `${x}px`;
76
+ floating.style.top = `${y}px`;
77
+ });
78
+ };
79
+
80
+ update();
81
+ cleanupRef.current = autoUpdate(reference, floating, update);
82
+
83
+ return () => {
84
+ if (cleanupRef.current) {
85
+ cleanupRef.current();
86
+ }
87
+ };
88
+ }, [showHelp, height, referenceRef, floatingRef]);
89
+ }
90
+
91
+ function useCloseOnClickOutside(
92
+ floatingRef: MutableRefObject<HTMLDivElement | null>,
93
+ referenceRef: MutableRefObject<HTMLButtonElement | null>,
94
+ setShowHelp: (value: ((prevState: boolean) => boolean) | boolean) => void,
95
+ ) {
96
+ useEffect(() => {
97
+ const handleClickOutside = (event: MouseEvent) => {
98
+ const path = event.composedPath();
99
+ if (
100
+ floatingRef.current &&
101
+ !path.includes(floatingRef.current) &&
102
+ referenceRef.current &&
103
+ !path.includes(referenceRef.current)
104
+ ) {
105
+ setShowHelp(false);
106
+ }
107
+ };
108
+
109
+ document.addEventListener('mousedown', handleClickOutside);
110
+
111
+ return () => {
112
+ document.removeEventListener('mousedown', handleClickOutside);
113
+ };
114
+ }, [floatingRef, referenceRef, setShowHelp]);
115
+ }
116
+
117
+ function useCloseOnEsc(setShowHelp: (value: ((prevState: boolean) => boolean) | boolean) => void) {
118
+ useEffect(() => {
119
+ const handleKeyDown = (event: KeyboardEvent) => {
120
+ if (event.key === 'Escape') {
121
+ setShowHelp(false);
122
+ }
123
+ };
124
+
125
+ document.addEventListener('keydown', handleKeyDown);
126
+
127
+ return () => {
128
+ document.removeEventListener('keydown', handleKeyDown);
129
+ };
130
+ }, [setShowHelp]);
131
+ }
132
+
40
133
  export const InfoHeadline1: FunctionComponent = ({ children }) => {
41
134
  return <h1 className='text-lg font-bold'>{children}</h1>;
42
135
  };
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { computeInitialValues } from './computeInitialValues';
4
+ import { PRESET_VALUE_CUSTOM, PRESET_VALUE_LAST_3_MONTHS, PRESET_VALUE_LAST_6_MONTHS } from './selectableOptions';
5
+
6
+ const today = new Date();
7
+ const earliestDate = '1900-01-01';
8
+
9
+ describe('computeInitialValues', () => {
10
+ it('should compute for initial value if initial "from" and "to" are unset', () => {
11
+ const result = computeInitialValues(PRESET_VALUE_LAST_3_MONTHS, undefined, undefined, earliestDate, []);
12
+
13
+ const expectedFrom = new Date();
14
+ expectedFrom.setMonth(today.getMonth() - 3);
15
+
16
+ expect(result.initialSelectedDateRange).toEqual(PRESET_VALUE_LAST_3_MONTHS);
17
+ expectDateMatches(result.initialSelectedDateFrom, expectedFrom);
18
+ expectDateMatches(result.initialSelectedDateTo, today);
19
+ });
20
+
21
+ it('should fall back to default when initial value is unknown', () => {
22
+ const result = computeInitialValues('not a known value', undefined, undefined, earliestDate, []);
23
+
24
+ const expectedFrom = new Date();
25
+ expectedFrom.setMonth(today.getMonth() - 6);
26
+
27
+ expect(result.initialSelectedDateRange).toEqual(PRESET_VALUE_LAST_6_MONTHS);
28
+ expectDateMatches(result.initialSelectedDateFrom, expectedFrom);
29
+ expectDateMatches(result.initialSelectedDateTo, today);
30
+ });
31
+
32
+ it('should overwrite initial value if initial "from" is set', () => {
33
+ const initialDateFrom = '2020-01-01';
34
+ const result = computeInitialValues(PRESET_VALUE_LAST_3_MONTHS, initialDateFrom, undefined, earliestDate, []);
35
+
36
+ expect(result.initialSelectedDateRange).toEqual(PRESET_VALUE_CUSTOM);
37
+ expectDateMatches(result.initialSelectedDateFrom, new Date(initialDateFrom));
38
+ expectDateMatches(result.initialSelectedDateTo, today);
39
+ });
40
+
41
+ it('should overwrite initial value if initial "to" is set', () => {
42
+ const initialDateTo = '2020-01-01';
43
+ const result = computeInitialValues(PRESET_VALUE_LAST_3_MONTHS, undefined, initialDateTo, earliestDate, []);
44
+
45
+ expect(result.initialSelectedDateRange).toEqual(PRESET_VALUE_CUSTOM);
46
+ expectDateMatches(result.initialSelectedDateFrom, new Date(earliestDate));
47
+ expectDateMatches(result.initialSelectedDateTo, new Date(initialDateTo));
48
+ });
49
+
50
+ it('should overwrite initial value if initial "to" and "from" are set', () => {
51
+ const initialDateFrom = '2020-01-01';
52
+ const initialDateTo = '2022-01-01';
53
+ const result = computeInitialValues(
54
+ PRESET_VALUE_LAST_3_MONTHS,
55
+ initialDateFrom,
56
+ initialDateTo,
57
+ earliestDate,
58
+ [],
59
+ );
60
+
61
+ expect(result.initialSelectedDateRange).toEqual(PRESET_VALUE_CUSTOM);
62
+ expectDateMatches(result.initialSelectedDateFrom, new Date(initialDateFrom));
63
+ expectDateMatches(result.initialSelectedDateTo, new Date(initialDateTo));
64
+ });
65
+
66
+ it('should set initial "to" to "from" if "from" is after "to"', () => {
67
+ const initialDateFrom = '2020-01-01';
68
+ const initialDateTo = '1900-01-01';
69
+ const result = computeInitialValues(
70
+ PRESET_VALUE_LAST_3_MONTHS,
71
+ initialDateFrom,
72
+ initialDateTo,
73
+ earliestDate,
74
+ [],
75
+ );
76
+
77
+ expect(result.initialSelectedDateRange).toEqual(PRESET_VALUE_CUSTOM);
78
+ expectDateMatches(result.initialSelectedDateFrom, new Date(initialDateFrom));
79
+ expectDateMatches(result.initialSelectedDateTo, new Date(initialDateFrom));
80
+ });
81
+
82
+ it('should throw if initial "from" is not a valid date', () => {
83
+ expect(() =>
84
+ computeInitialValues(PRESET_VALUE_LAST_3_MONTHS, 'not a date', undefined, earliestDate, []),
85
+ ).toThrowError('Invalid initialDateFrom');
86
+ });
87
+
88
+ it('should throw if initial "to" is not a valid date', () => {
89
+ expect(() =>
90
+ computeInitialValues(PRESET_VALUE_LAST_3_MONTHS, undefined, 'not a date', earliestDate, []),
91
+ ).toThrowError('Invalid initialDateTo');
92
+ });
93
+
94
+ function expectDateMatches(actual: Date, expected: Date) {
95
+ expect(actual.getFullYear()).toEqual(expected.getFullYear());
96
+ expect(actual.getMonth()).toEqual(expected.getMonth());
97
+ expect(actual.getDate()).toEqual(expected.getDate());
98
+ }
99
+ });
@@ -0,0 +1,73 @@
1
+ import {
2
+ type CustomSelectOption,
3
+ getDatesForSelectorValue,
4
+ getSelectableOptions,
5
+ PRESET_VALUE_CUSTOM,
6
+ PRESET_VALUE_LAST_6_MONTHS,
7
+ type PresetOptionValues,
8
+ } from './selectableOptions';
9
+ import { UserFacingError } from '../components/error-display';
10
+
11
+ export function computeInitialValues<CustomLabel extends string>(
12
+ initialValue: PresetOptionValues | CustomLabel | undefined,
13
+ initialDateFrom: string | undefined,
14
+ initialDateTo: string | undefined,
15
+ earliestDate: string,
16
+ customSelectOptions: CustomSelectOption<CustomLabel>[],
17
+ ): {
18
+ initialSelectedDateRange: CustomLabel | PresetOptionValues;
19
+ initialSelectedDateFrom: Date;
20
+ initialSelectedDateTo: Date;
21
+ } {
22
+ if (isUndefinedOrEmpty(initialDateFrom) && isUndefinedOrEmpty(initialDateTo)) {
23
+ const selectableOptions = getSelectableOptions(customSelectOptions);
24
+ const initialSelectedDateRange =
25
+ initialValue !== undefined && selectableOptions.some((option) => option.value === initialValue)
26
+ ? initialValue
27
+ : PRESET_VALUE_LAST_6_MONTHS;
28
+
29
+ const { dateFrom, dateTo } = getDatesForSelectorValue(
30
+ initialSelectedDateRange,
31
+ customSelectOptions,
32
+ earliestDate,
33
+ );
34
+
35
+ return {
36
+ initialSelectedDateRange,
37
+ initialSelectedDateFrom: dateFrom,
38
+ initialSelectedDateTo: dateTo,
39
+ };
40
+ }
41
+
42
+ const initialSelectedDateFrom = isUndefinedOrEmpty(initialDateFrom)
43
+ ? new Date(earliestDate)
44
+ : new Date(initialDateFrom);
45
+ let initialSelectedDateTo = isUndefinedOrEmpty(initialDateTo) ? new Date() : new Date(initialDateTo);
46
+
47
+ if (isNaN(initialSelectedDateFrom.getTime())) {
48
+ throw new UserFacingError(
49
+ 'Invalid initialDateFrom',
50
+ `Invalid initialDateFrom "${initialDateFrom}", It must be of the format YYYY-MM-DD`,
51
+ );
52
+ }
53
+ if (isNaN(initialSelectedDateTo.getTime())) {
54
+ throw new UserFacingError(
55
+ 'Invalid initialDateTo',
56
+ `Invalid initialDateTo "${initialDateTo}", It must be of the format YYYY-MM-DD`,
57
+ );
58
+ }
59
+
60
+ if (initialSelectedDateFrom > initialSelectedDateTo) {
61
+ initialSelectedDateTo = initialSelectedDateFrom;
62
+ }
63
+
64
+ return {
65
+ initialSelectedDateRange: PRESET_VALUE_CUSTOM,
66
+ initialSelectedDateFrom,
67
+ initialSelectedDateTo,
68
+ };
69
+ }
70
+
71
+ function isUndefinedOrEmpty(value: string | undefined): value is undefined | '' {
72
+ return value === undefined || value === '';
73
+ }