@tcn/ui-time-selector 1.0.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/dist/components/date_range_panel/date_range_panel.d.ts +13 -0
- package/dist/components/date_range_panel/date_range_panel.d.ts.map +1 -0
- package/dist/components/date_range_panel/date_range_panel.js +71 -0
- package/dist/components/date_range_panel/date_range_panel.js.map +1 -0
- package/dist/components/date_range_panel/date_range_panel_presenter.d.ts +14 -0
- package/dist/components/date_range_panel/date_range_panel_presenter.d.ts.map +1 -0
- package/dist/components/date_range_panel/date_range_panel_presenter.js +24 -0
- package/dist/components/date_range_panel/date_range_panel_presenter.js.map +1 -0
- package/dist/components/preset_panel/preset_panel.d.ts +11 -0
- package/dist/components/preset_panel/preset_panel.d.ts.map +1 -0
- package/dist/components/preset_panel/preset_panel.js +52 -0
- package/dist/components/preset_panel/preset_panel.js.map +1 -0
- package/dist/components/preset_panel/preset_panel_presenter.d.ts +24 -0
- package/dist/components/preset_panel/preset_panel_presenter.d.ts.map +1 -0
- package/dist/components/preset_panel/preset_panel_presenter.js +39 -0
- package/dist/components/preset_panel/preset_panel_presenter.js.map +1 -0
- package/dist/components/time_selector/time_selector.d.ts +12 -0
- package/dist/components/time_selector/time_selector.d.ts.map +1 -0
- package/dist/components/time_selector/time_selector.js +105 -0
- package/dist/components/time_selector/time_selector.js.map +1 -0
- package/dist/components/time_selector/time_selector_presenter.d.ts +28 -0
- package/dist/components/time_selector/time_selector_presenter.d.ts.map +1 -0
- package/dist/components/time_selector/time_selector_presenter.js +66 -0
- package/dist/components/time_selector/time_selector_presenter.js.map +1 -0
- package/dist/components/timezone_footer/timezone_footer.d.ts +9 -0
- package/dist/components/timezone_footer/timezone_footer.d.ts.map +1 -0
- package/dist/components/timezone_footer/timezone_footer.js +78 -0
- package/dist/components/timezone_footer/timezone_footer.js.map +1 -0
- package/dist/components/timezone_footer/timezone_footer_presenter.d.ts +23 -0
- package/dist/components/timezone_footer/timezone_footer_presenter.d.ts.map +1 -0
- package/dist/components/timezone_footer/timezone_footer_presenter.js +56 -0
- package/dist/components/timezone_footer/timezone_footer_presenter.js.map +1 -0
- package/dist/components/utils.d.ts +32 -0
- package/dist/components/utils.d.ts.map +1 -0
- package/dist/components/utils.js +62 -0
- package/dist/components/utils.js.map +1 -0
- package/dist/date_range_panel.css +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/preset_panel.css +1 -0
- package/dist/time_selector.css +1 -0
- package/dist/timezone_footer.css +1 -0
- package/package.json +72 -0
- package/src/__stories__/time_selector.stories.tsx +99 -0
- package/src/components/date_range_panel/date_range_panel.module.css +31 -0
- package/src/components/date_range_panel/date_range_panel.tsx +87 -0
- package/src/components/date_range_panel/date_range_panel_presenter.ts +35 -0
- package/src/components/preset_panel/preset_panel.module.css +35 -0
- package/src/components/preset_panel/preset_panel.tsx +74 -0
- package/src/components/preset_panel/preset_panel_presenter.ts +68 -0
- package/src/components/time_selector/time_selector.module.css +27 -0
- package/src/components/time_selector/time_selector.tsx +100 -0
- package/src/components/time_selector/time_selector_presenter.ts +116 -0
- package/src/components/timezone_footer/timezone_footer.module.css +28 -0
- package/src/components/timezone_footer/timezone_footer.tsx +109 -0
- package/src/components/timezone_footer/timezone_footer_presenter.ts +83 -0
- package/src/components/utils.ts +95 -0
- package/src/index.ts +5 -0
- package/tsconfig.json +7 -0
- package/types/file_types.d.ts +54 -0
- package/types/react_color.d.ts +61 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Signal } from '@tcn/state';
|
|
2
|
+
|
|
3
|
+
export class DateRangePanelPresenter {
|
|
4
|
+
private _isApplyDisabled = new Signal<boolean>(false);
|
|
5
|
+
|
|
6
|
+
readonly broadcasts: {
|
|
7
|
+
isApplyDisabled: Signal<boolean>['broadcast'];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
readonly startDate: Signal<Date | null>,
|
|
12
|
+
readonly endDate: Signal<Date | null>
|
|
13
|
+
) {
|
|
14
|
+
this.broadcasts = {
|
|
15
|
+
isApplyDisabled: this._isApplyDisabled.broadcast,
|
|
16
|
+
};
|
|
17
|
+
this._updateApplyDisabled();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private _updateApplyDisabled() {
|
|
21
|
+
const start = this.startDate.get();
|
|
22
|
+
const end = this.endDate.get();
|
|
23
|
+
this._isApplyDisabled.set(start == null || end == null || end <= start);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setStartDate(date: Date | null) {
|
|
27
|
+
this.startDate.set(date);
|
|
28
|
+
this._updateApplyDisabled();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setEndDate(date: Date | null) {
|
|
32
|
+
this.endDate.set(date);
|
|
33
|
+
this._updateApplyDisabled();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.presets-panel {
|
|
2
|
+
flex: 1;
|
|
3
|
+
padding: 8px;
|
|
4
|
+
gap: 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.preset-search {
|
|
8
|
+
margin-bottom: 8px;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.preset-list {
|
|
12
|
+
overflow-x: hidden;
|
|
13
|
+
overflow-y: auto;
|
|
14
|
+
gap: 2px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.preset-item {
|
|
18
|
+
width: 100%;
|
|
19
|
+
padding: 6px 8px;
|
|
20
|
+
margin-left: 6px;
|
|
21
|
+
border-radius: 4px;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.preset-item:hover {
|
|
26
|
+
background: var(--tcn-color-surface-hover, #f3f4f6);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.preset-item[data-selected="true"] {
|
|
30
|
+
background: var(--tcn-color-surface-selected, #eff6ff);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.preset-item[data-highlighted="true"] {
|
|
34
|
+
background: var(--tcn-color-surface-hover, #e5e7eb);
|
|
35
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useSignalValue } from '@tcn/state';
|
|
2
|
+
import { HStack, VStack } from '@tcn/ui/stacks';
|
|
3
|
+
import { BodyText } from '@tcn/ui/typography';
|
|
4
|
+
import { Input } from '@tcn/ui/inputs';
|
|
5
|
+
import { useEffect, useRef } from 'react';
|
|
6
|
+
import { type PresetItem } from '../utils.js';
|
|
7
|
+
import { PresetPanelPresenter } from './preset_panel_presenter.js';
|
|
8
|
+
import styles from './preset_panel.module.css';
|
|
9
|
+
|
|
10
|
+
export interface PresetPanelOwnProps {
|
|
11
|
+
presenter: PresetPanelPresenter;
|
|
12
|
+
selectedPreset: PresetItem | null;
|
|
13
|
+
focused: boolean;
|
|
14
|
+
onSelect: (item: PresetItem) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PresetPanelProps = PresetPanelOwnProps;
|
|
18
|
+
|
|
19
|
+
export function PresetPanel({
|
|
20
|
+
presenter,
|
|
21
|
+
selectedPreset,
|
|
22
|
+
focused,
|
|
23
|
+
onSelect,
|
|
24
|
+
}: PresetPanelProps) {
|
|
25
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
26
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
|
|
28
|
+
const search = useSignalValue(presenter.broadcasts.search);
|
|
29
|
+
const filteredItems = useSignalValue(presenter.broadcasts.filteredItems);
|
|
30
|
+
const highlightedIndex = useSignalValue(presenter.broadcasts.highlightedIndex);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (focused) {
|
|
34
|
+
searchRef.current?.focus();
|
|
35
|
+
}
|
|
36
|
+
}, [focused]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const list = listRef.current;
|
|
40
|
+
if (list == null) return;
|
|
41
|
+
const item = list.children[highlightedIndex] as HTMLElement | undefined;
|
|
42
|
+
item?.scrollIntoView({ block: 'nearest' });
|
|
43
|
+
}, [highlightedIndex]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<VStack className={styles['presets-panel']} height="auto">
|
|
47
|
+
<Input
|
|
48
|
+
ref={searchRef}
|
|
49
|
+
className={styles['preset-search']}
|
|
50
|
+
value={search}
|
|
51
|
+
onChange={v => presenter.setSearch(v)}
|
|
52
|
+
placeholder="Search quick ranges"
|
|
53
|
+
onKeyDown={e => {
|
|
54
|
+
const result = presenter.handleKeyDown(e.key);
|
|
55
|
+
if (result.preventDefault) e.preventDefault();
|
|
56
|
+
if (result.selectedItem != null) onSelect(result.selectedItem);
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
<VStack ref={listRef} className={styles['preset-list']} height="290px" width="100%">
|
|
60
|
+
{filteredItems.map((item, index) => (
|
|
61
|
+
<HStack
|
|
62
|
+
key={item.label}
|
|
63
|
+
className={styles['preset-item']}
|
|
64
|
+
data-selected={selectedPreset != null && item.label === selectedPreset.label}
|
|
65
|
+
data-highlighted={index === highlightedIndex}
|
|
66
|
+
onClick={() => onSelect(item)}
|
|
67
|
+
>
|
|
68
|
+
<BodyText selectable={false}>{item.label}</BodyText>
|
|
69
|
+
</HStack>
|
|
70
|
+
))}
|
|
71
|
+
</VStack>
|
|
72
|
+
</VStack>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Signal, derive } from '@tcn/state';
|
|
2
|
+
import { type PresetItem } from '../utils.js';
|
|
3
|
+
|
|
4
|
+
export class PresetPanelPresenter {
|
|
5
|
+
private _search = new Signal<string>('');
|
|
6
|
+
private _highlightedIndex = new Signal<number>(0);
|
|
7
|
+
private _filteredItems: ReturnType<typeof derive<string, PresetItem[]>>;
|
|
8
|
+
|
|
9
|
+
readonly broadcasts: {
|
|
10
|
+
search: Signal<string>['broadcast'];
|
|
11
|
+
highlightedIndex: Signal<number>['broadcast'];
|
|
12
|
+
filteredItems: ReturnType<typeof derive<string, PresetItem[]>>['broadcast'];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
constructor(private readonly _items: PresetItem[]) {
|
|
16
|
+
this._filteredItems = derive(this._search, search =>
|
|
17
|
+
_items.filter(item => item.label.toLowerCase().includes(search.toLowerCase()))
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
this.broadcasts = {
|
|
21
|
+
search: this._search.broadcast,
|
|
22
|
+
highlightedIndex: this._highlightedIndex.broadcast,
|
|
23
|
+
filteredItems: this._filteredItems.broadcast,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
setSearch(query: string) {
|
|
28
|
+
this._search.set(query);
|
|
29
|
+
this._highlightedIndex.set(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
moveHighlight(direction: 1 | -1) {
|
|
33
|
+
const count = this._filteredItems.get().length;
|
|
34
|
+
if (count === 0) return;
|
|
35
|
+
const next = (this._highlightedIndex.get() + direction + count) % count;
|
|
36
|
+
this._highlightedIndex.set(next);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getHighlightedItem(): PresetItem | null {
|
|
40
|
+
const items = this._filteredItems.get();
|
|
41
|
+
return items[this._highlightedIndex.get()] ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
handleKeyDown(key: string): {
|
|
45
|
+
preventDefault: boolean;
|
|
46
|
+
selectedItem?: PresetItem;
|
|
47
|
+
escape?: boolean;
|
|
48
|
+
} {
|
|
49
|
+
if (key === 'ArrowDown') {
|
|
50
|
+
this.moveHighlight(1);
|
|
51
|
+
return { preventDefault: true };
|
|
52
|
+
} else if (key === 'ArrowUp') {
|
|
53
|
+
this.moveHighlight(-1);
|
|
54
|
+
return { preventDefault: true };
|
|
55
|
+
} else if (key === 'Enter') {
|
|
56
|
+
const item = this.getHighlightedItem();
|
|
57
|
+
return { preventDefault: true, selectedItem: item ?? undefined };
|
|
58
|
+
} else if (key === 'Escape') {
|
|
59
|
+
return { preventDefault: false, escape: true };
|
|
60
|
+
}
|
|
61
|
+
return { preventDefault: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
reset() {
|
|
65
|
+
this._search.set('');
|
|
66
|
+
this._highlightedIndex.set(0);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.trigger-abbreviation {
|
|
2
|
+
color: var(--ergo-primary, #2563eb);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.trigger {
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
border: 1px solid var(--tcn-color-border, #d1d5db);
|
|
9
|
+
border-radius: 4px;
|
|
10
|
+
padding: 4px 8px;
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
user-select: none;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.trigger:hover {
|
|
16
|
+
border-color: var(--tcn-color-border-hover, #9ca3af);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.popper-content {
|
|
20
|
+
background: var(--tcn-color-surface-primary, #ffffff);
|
|
21
|
+
border: 1px solid var(--tcn-color-border, #d1d5db);
|
|
22
|
+
border-radius: 6px;
|
|
23
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
|
24
|
+
overflow: hidden;
|
|
25
|
+
align-items: flex-start;
|
|
26
|
+
width: 400px;
|
|
27
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useSignalValue } from '@tcn/state';
|
|
2
|
+
import { ElementPopper } from '@tcn/ui/overlay';
|
|
3
|
+
import { HStack, VStack } from '@tcn/ui/stacks';
|
|
4
|
+
import { BodyText } from '@tcn/ui/typography';
|
|
5
|
+
import { useRef, useState } from 'react';
|
|
6
|
+
import { TimeSelectorPresenter } from './time_selector_presenter.js';
|
|
7
|
+
import { PresetPanel } from '../preset_panel/preset_panel.js';
|
|
8
|
+
import { DateRangePanel } from '../date_range_panel/date_range_panel.js';
|
|
9
|
+
import { TimezoneFooter } from '../timezone_footer/timezone_footer.js';
|
|
10
|
+
import styles from './time_selector.module.css';
|
|
11
|
+
|
|
12
|
+
export interface TimeSelectorOwnProps {
|
|
13
|
+
onChange?: (start: Date, end: Date) => void;
|
|
14
|
+
onTimezoneChange?: (timezone: string) => void;
|
|
15
|
+
startDateMin?: Date | null;
|
|
16
|
+
startDateMax?: Date | null;
|
|
17
|
+
endDateMin?: Date | null;
|
|
18
|
+
endDateMax?: Date | null;
|
|
19
|
+
timezones?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type TimeSelectorProps = TimeSelectorOwnProps;
|
|
23
|
+
|
|
24
|
+
export function TimeSelector({
|
|
25
|
+
onChange,
|
|
26
|
+
onTimezoneChange,
|
|
27
|
+
startDateMin,
|
|
28
|
+
startDateMax,
|
|
29
|
+
endDateMin,
|
|
30
|
+
endDateMax,
|
|
31
|
+
timezones,
|
|
32
|
+
}: TimeSelectorProps) {
|
|
33
|
+
const [presenter] = useState(() => new TimeSelectorPresenter());
|
|
34
|
+
const anchorRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
|
|
36
|
+
const isOpen = useSignalValue(presenter.broadcasts.isOpen);
|
|
37
|
+
const selectedPreset = useSignalValue(presenter.broadcasts.selectedPreset);
|
|
38
|
+
const displayValue = useSignalValue(presenter.broadcasts.displayValue);
|
|
39
|
+
const displayAbbreviation = useSignalValue(
|
|
40
|
+
presenter.timezoneFooterPresenter.broadcasts.timezoneAbbreviation
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<div
|
|
46
|
+
ref={anchorRef}
|
|
47
|
+
className={styles.trigger}
|
|
48
|
+
onClick={() => presenter.toggleOpen()}
|
|
49
|
+
>
|
|
50
|
+
<HStack height="auto" width="auto" gap="4px">
|
|
51
|
+
<BodyText selectable={false}>{displayValue},</BodyText>
|
|
52
|
+
<BodyText
|
|
53
|
+
selectable={false}
|
|
54
|
+
style={{ fontWeight: 700 }}
|
|
55
|
+
className={styles['trigger-abbreviation']}
|
|
56
|
+
>
|
|
57
|
+
{displayAbbreviation}
|
|
58
|
+
</BodyText>
|
|
59
|
+
</HStack>
|
|
60
|
+
</div>
|
|
61
|
+
<ElementPopper
|
|
62
|
+
anchorElement={anchorRef}
|
|
63
|
+
open={isOpen}
|
|
64
|
+
onDismissal={() => presenter.close()}
|
|
65
|
+
dismissals={[]}
|
|
66
|
+
>
|
|
67
|
+
<VStack
|
|
68
|
+
className={styles['popper-content']}
|
|
69
|
+
height="auto"
|
|
70
|
+
onKeyDown={e => {
|
|
71
|
+
if (e.key === 'Escape') presenter.close();
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<HStack height="auto" width="100%" vAlign="start">
|
|
75
|
+
<DateRangePanel
|
|
76
|
+
presenter={presenter.dateRangePanelPresenter}
|
|
77
|
+
focused={isOpen && selectedPreset == null}
|
|
78
|
+
startDateMin={startDateMin}
|
|
79
|
+
startDateMax={startDateMax}
|
|
80
|
+
endDateMin={endDateMin}
|
|
81
|
+
endDateMax={endDateMax}
|
|
82
|
+
onApply={() => presenter.applyTimeRange(onChange)}
|
|
83
|
+
/>
|
|
84
|
+
<PresetPanel
|
|
85
|
+
presenter={presenter.presetPanelPresenter}
|
|
86
|
+
selectedPreset={selectedPreset}
|
|
87
|
+
focused={isOpen && selectedPreset != null}
|
|
88
|
+
onSelect={item => presenter.handlePresetSelect(item, onChange)}
|
|
89
|
+
/>
|
|
90
|
+
</HStack>
|
|
91
|
+
<TimezoneFooter
|
|
92
|
+
presenter={presenter.timezoneFooterPresenter}
|
|
93
|
+
timezones={timezones}
|
|
94
|
+
onTimezoneChange={onTimezoneChange}
|
|
95
|
+
/>
|
|
96
|
+
</VStack>
|
|
97
|
+
</ElementPopper>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Signal } from '@tcn/state';
|
|
2
|
+
import {
|
|
3
|
+
formatDate,
|
|
4
|
+
datesFromPreset,
|
|
5
|
+
shiftForDisplay,
|
|
6
|
+
unshiftFromDisplay,
|
|
7
|
+
type PresetItem,
|
|
8
|
+
} from '../utils.js';
|
|
9
|
+
import { PresetPanelPresenter } from '../preset_panel/preset_panel_presenter.js';
|
|
10
|
+
import { DateRangePanelPresenter } from '../date_range_panel/date_range_panel_presenter.js';
|
|
11
|
+
import { TimezoneFooterPresenter } from '../timezone_footer/timezone_footer_presenter.js';
|
|
12
|
+
|
|
13
|
+
export type { PresetItem };
|
|
14
|
+
|
|
15
|
+
export const PRESET_ITEMS: PresetItem[] = [
|
|
16
|
+
{ label: 'Last 5 mins', minutes: 5 },
|
|
17
|
+
{ label: 'Last 15 mins', minutes: 15 },
|
|
18
|
+
{ label: 'Last 30 mins', minutes: 30 },
|
|
19
|
+
{ label: 'Last 1 hour', minutes: 60 },
|
|
20
|
+
{ label: 'Last 3 hours', minutes: 180 },
|
|
21
|
+
{ label: 'Last 6 hours', minutes: 360 },
|
|
22
|
+
{ label: 'Last 12 hours', minutes: 720 },
|
|
23
|
+
{ label: 'Last 24 hours', minutes: 1440 },
|
|
24
|
+
{ label: 'Last 2 days', minutes: 2880 },
|
|
25
|
+
{ label: 'Last 7 days', minutes: 10080 },
|
|
26
|
+
{ label: 'Last 30 days', minutes: 43200 },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export class TimeSelectorPresenter {
|
|
30
|
+
private _isOpen = new Signal<boolean>(false);
|
|
31
|
+
private _selectedPreset = new Signal<PresetItem | null>(PRESET_ITEMS[0]);
|
|
32
|
+
private _displayValue: Signal<string>;
|
|
33
|
+
private _startDate: Signal<Date | null>;
|
|
34
|
+
private _endDate: Signal<Date | null>;
|
|
35
|
+
|
|
36
|
+
readonly presetPanelPresenter: PresetPanelPresenter;
|
|
37
|
+
readonly dateRangePanelPresenter: DateRangePanelPresenter;
|
|
38
|
+
readonly timezoneFooterPresenter: TimezoneFooterPresenter;
|
|
39
|
+
|
|
40
|
+
readonly broadcasts: {
|
|
41
|
+
isOpen: Signal<boolean>['broadcast'];
|
|
42
|
+
selectedPreset: Signal<PresetItem | null>['broadcast'];
|
|
43
|
+
displayValue: Signal<string>['broadcast'];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
constructor() {
|
|
47
|
+
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
48
|
+
const { start, end } = datesFromPreset(PRESET_ITEMS[0]);
|
|
49
|
+
|
|
50
|
+
this._startDate = new Signal<Date | null>(shiftForDisplay(start, browserTz));
|
|
51
|
+
this._endDate = new Signal<Date | null>(shiftForDisplay(end, browserTz));
|
|
52
|
+
|
|
53
|
+
this.presetPanelPresenter = new PresetPanelPresenter(PRESET_ITEMS);
|
|
54
|
+
this.dateRangePanelPresenter = new DateRangePanelPresenter(
|
|
55
|
+
this._startDate,
|
|
56
|
+
this._endDate
|
|
57
|
+
);
|
|
58
|
+
this.timezoneFooterPresenter = new TimezoneFooterPresenter(
|
|
59
|
+
this._startDate,
|
|
60
|
+
this._endDate
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
this._displayValue = new Signal<string>(PRESET_ITEMS[0].label);
|
|
64
|
+
|
|
65
|
+
this.broadcasts = {
|
|
66
|
+
isOpen: this._isOpen.broadcast,
|
|
67
|
+
selectedPreset: this._selectedPreset.broadcast,
|
|
68
|
+
displayValue: this._displayValue.broadcast,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handlePresetSelect(item: PresetItem, onChange?: (start: Date, end: Date) => void) {
|
|
73
|
+
this._selectedPreset.set(item);
|
|
74
|
+
|
|
75
|
+
const { start, end } = datesFromPreset(item);
|
|
76
|
+
const tz = this.timezoneFooterPresenter.getTimezone();
|
|
77
|
+
|
|
78
|
+
this._startDate.set(shiftForDisplay(start, tz));
|
|
79
|
+
this._endDate.set(shiftForDisplay(end, tz));
|
|
80
|
+
|
|
81
|
+
this._displayValue.set(item.label);
|
|
82
|
+
onChange?.(start, end);
|
|
83
|
+
this.close();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
applyTimeRange(onChange?: (start: Date, end: Date) => void) {
|
|
87
|
+
const displayStart = this._startDate.get();
|
|
88
|
+
const displayEnd = this._endDate.get();
|
|
89
|
+
|
|
90
|
+
if (displayStart != null && displayEnd != null) {
|
|
91
|
+
const tz = this.timezoneFooterPresenter.getTimezone();
|
|
92
|
+
const realStart = unshiftFromDisplay(displayStart, tz);
|
|
93
|
+
const realEnd = unshiftFromDisplay(displayEnd, tz);
|
|
94
|
+
|
|
95
|
+
this._selectedPreset.set(null);
|
|
96
|
+
this._displayValue.set(`${formatDate(displayStart)} to ${formatDate(displayEnd)}`);
|
|
97
|
+
|
|
98
|
+
onChange?.(realStart, realEnd);
|
|
99
|
+
|
|
100
|
+
this.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
toggleOpen() {
|
|
105
|
+
if (this._isOpen.get()) {
|
|
106
|
+
this.close();
|
|
107
|
+
} else {
|
|
108
|
+
this._isOpen.set(true);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
close() {
|
|
113
|
+
this._isOpen.set(false);
|
|
114
|
+
this.presetPanelPresenter.reset();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.utc-footer {
|
|
2
|
+
border-top: 1px solid var(--tcn-color-border, #d1d5db);
|
|
3
|
+
padding: 8px 12px;
|
|
4
|
+
gap: 8px;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.utc-label {
|
|
8
|
+
color: var(--tcn-color-text-secondary, #6b7280);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.utc-offset {
|
|
12
|
+
padding-right: 4px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.timezone-settings-button {
|
|
16
|
+
flex-shrink: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.timezone-select {
|
|
20
|
+
width: 100%;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.timezone-option-offset {
|
|
24
|
+
color: var(--tcn-color-text-secondary, #6b7280);
|
|
25
|
+
min-width: 80px;
|
|
26
|
+
text-align: right;
|
|
27
|
+
padding-right: 4px;
|
|
28
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useSignalValue } from '@tcn/state';
|
|
2
|
+
import { HStack, Spacer, VStack } from '@tcn/ui/stacks';
|
|
3
|
+
import { BodyText } from '@tcn/ui/typography';
|
|
4
|
+
import { Option, Select } from '@tcn/ui/inputs';
|
|
5
|
+
import { Button } from '@tcn/ui/actions';
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
import { ChevronDownIcon } from '@tcn/icons/chevron_down_icon.js';
|
|
8
|
+
import { ChevronUpIcon } from '@tcn/icons/chevron_up_icon.js';
|
|
9
|
+
import {
|
|
10
|
+
formatUTCOffset,
|
|
11
|
+
getTimezoneAbbreviation,
|
|
12
|
+
getTimezoneOffsetMinutes,
|
|
13
|
+
} from '../utils.js';
|
|
14
|
+
import { TimezoneFooterPresenter } from './timezone_footer_presenter.js';
|
|
15
|
+
import styles from './timezone_footer.module.css';
|
|
16
|
+
|
|
17
|
+
interface TimezoneOption {
|
|
18
|
+
iana: string;
|
|
19
|
+
abbreviation: string;
|
|
20
|
+
utcOffset: string;
|
|
21
|
+
offsetMinutes: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TimezoneFooterOwnProps {
|
|
25
|
+
presenter: TimezoneFooterPresenter;
|
|
26
|
+
timezones?: string[];
|
|
27
|
+
onTimezoneChange?: (tz: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type TimezoneFooterProps = TimezoneFooterOwnProps;
|
|
31
|
+
|
|
32
|
+
export function TimezoneFooter({
|
|
33
|
+
presenter,
|
|
34
|
+
timezones,
|
|
35
|
+
onTimezoneChange,
|
|
36
|
+
}: TimezoneFooterProps) {
|
|
37
|
+
const selectedTimezone = useSignalValue(presenter.broadcasts.timezone);
|
|
38
|
+
const isSelectOpen = useSignalValue(presenter.broadcasts.isSelectOpen);
|
|
39
|
+
const timezoneAbbreviation = useSignalValue(presenter.broadcasts.timezoneAbbreviation);
|
|
40
|
+
const timezoneLabel = useSignalValue(presenter.broadcasts.timezoneLabel);
|
|
41
|
+
const utcOffset = useSignalValue(presenter.broadcasts.utcOffset);
|
|
42
|
+
|
|
43
|
+
const timezoneOptions = useMemo<TimezoneOption[]>(() => {
|
|
44
|
+
const ianas: string[] =
|
|
45
|
+
timezones ?? ((Intl as any).supportedValuesOf('timeZone') as string[]);
|
|
46
|
+
const now = new Date();
|
|
47
|
+
return ianas
|
|
48
|
+
.map(iana => {
|
|
49
|
+
const offsetMinutes = getTimezoneOffsetMinutes(iana, now);
|
|
50
|
+
return {
|
|
51
|
+
iana,
|
|
52
|
+
abbreviation: getTimezoneAbbreviation(iana, now),
|
|
53
|
+
utcOffset: formatUTCOffset(offsetMinutes),
|
|
54
|
+
offsetMinutes,
|
|
55
|
+
};
|
|
56
|
+
})
|
|
57
|
+
.sort((a, b) => b.offsetMinutes - a.offsetMinutes);
|
|
58
|
+
}, [timezones]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<VStack className={styles['utc-footer']} height="auto" width="100%">
|
|
62
|
+
<HStack height="auto" width="100%">
|
|
63
|
+
<BodyText className={styles['utc-label']} selectable={false} size="md">
|
|
64
|
+
{timezoneLabel}
|
|
65
|
+
</BodyText>
|
|
66
|
+
<Spacer />
|
|
67
|
+
<BodyText selectable={false} size="md" className={styles['utc-offset']}>
|
|
68
|
+
{utcOffset}
|
|
69
|
+
</BodyText>
|
|
70
|
+
<Button
|
|
71
|
+
size="sm"
|
|
72
|
+
hierarchy="secondary"
|
|
73
|
+
className={styles['timezone-settings-button']}
|
|
74
|
+
onClick={() => presenter.toggleSelect()}
|
|
75
|
+
>
|
|
76
|
+
Change time settings
|
|
77
|
+
{isSelectOpen ? <ChevronUpIcon size="sm" /> : <ChevronDownIcon size="sm" />}
|
|
78
|
+
</Button>
|
|
79
|
+
</HStack>
|
|
80
|
+
{isSelectOpen && (
|
|
81
|
+
<Select
|
|
82
|
+
value={selectedTimezone}
|
|
83
|
+
onChange={tz => presenter.setTimezone(tz, onTimezoneChange)}
|
|
84
|
+
hierarchy="secondary"
|
|
85
|
+
className={styles['timezone-select']}
|
|
86
|
+
size="sm"
|
|
87
|
+
>
|
|
88
|
+
{timezoneOptions.map(tz => (
|
|
89
|
+
<Option
|
|
90
|
+
key={tz.iana}
|
|
91
|
+
value={tz.iana}
|
|
92
|
+
label={timezoneAbbreviation}
|
|
93
|
+
keywords={[tz.iana, tz.abbreviation]}
|
|
94
|
+
>
|
|
95
|
+
<HStack height="auto" width="100%" gap="8px">
|
|
96
|
+
<BodyText selectable={false}>{tz.iana}</BodyText>
|
|
97
|
+
<Spacer />
|
|
98
|
+
<BodyText selectable={false}>{tz.abbreviation}</BodyText>
|
|
99
|
+
<BodyText className={styles['timezone-option-offset']} selectable={false}>
|
|
100
|
+
{tz.utcOffset}
|
|
101
|
+
</BodyText>
|
|
102
|
+
</HStack>
|
|
103
|
+
</Option>
|
|
104
|
+
))}
|
|
105
|
+
</Select>
|
|
106
|
+
)}
|
|
107
|
+
</VStack>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Signal, derive } from '@tcn/state';
|
|
2
|
+
import {
|
|
3
|
+
formatUTCOffset,
|
|
4
|
+
getTimezoneAbbreviation,
|
|
5
|
+
getTimezoneOffsetMinutes,
|
|
6
|
+
shiftForDisplay,
|
|
7
|
+
unshiftFromDisplay,
|
|
8
|
+
} from '../utils.js';
|
|
9
|
+
|
|
10
|
+
export class TimezoneFooterPresenter {
|
|
11
|
+
private _timezone: Signal<string>;
|
|
12
|
+
private _isSelectOpen = new Signal<boolean>(false);
|
|
13
|
+
private _timezoneAbbreviation: ReturnType<typeof derive<string, string>>;
|
|
14
|
+
private _timezoneLabel: ReturnType<typeof derive<string, string>>;
|
|
15
|
+
private _utcOffset: ReturnType<typeof derive<string, string>>;
|
|
16
|
+
|
|
17
|
+
readonly broadcasts: {
|
|
18
|
+
timezone: Signal<string>['broadcast'];
|
|
19
|
+
isSelectOpen: Signal<boolean>['broadcast'];
|
|
20
|
+
timezoneAbbreviation: ReturnType<typeof derive<string, string>>['broadcast'];
|
|
21
|
+
timezoneLabel: ReturnType<typeof derive<string, string>>['broadcast'];
|
|
22
|
+
utcOffset: ReturnType<typeof derive<string, string>>['broadcast'];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly _startDate: Signal<Date | null>,
|
|
27
|
+
private readonly _endDate: Signal<Date | null>
|
|
28
|
+
) {
|
|
29
|
+
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
30
|
+
this._timezone = new Signal<string>(browserTz);
|
|
31
|
+
|
|
32
|
+
this._timezoneAbbreviation = derive(this._timezone, tz =>
|
|
33
|
+
getTimezoneAbbreviation(tz, new Date())
|
|
34
|
+
);
|
|
35
|
+
this._timezoneLabel = derive(
|
|
36
|
+
this._timezone,
|
|
37
|
+
tz => `${tz}, ${getTimezoneAbbreviation(tz, new Date())}`
|
|
38
|
+
);
|
|
39
|
+
this._utcOffset = derive(this._timezone, tz =>
|
|
40
|
+
formatUTCOffset(getTimezoneOffsetMinutes(tz, new Date()))
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
this.broadcasts = {
|
|
44
|
+
timezone: this._timezone.broadcast,
|
|
45
|
+
isSelectOpen: this._isSelectOpen.broadcast,
|
|
46
|
+
timezoneAbbreviation: this._timezoneAbbreviation.broadcast,
|
|
47
|
+
timezoneLabel: this._timezoneLabel.broadcast,
|
|
48
|
+
utcOffset: this._utcOffset.broadcast,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getTimezone(): string {
|
|
53
|
+
return this._timezone.get();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getAbbreviation(): string {
|
|
57
|
+
return this._timezoneAbbreviation.get();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toggleSelect() {
|
|
61
|
+
this._isSelectOpen.set(!this._isSelectOpen.get());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setTimezone(iana: string, onTimezoneChange?: (tz: string) => void) {
|
|
65
|
+
const oldTz = this._timezone.get();
|
|
66
|
+
|
|
67
|
+
const currentStart = this._startDate.get();
|
|
68
|
+
if (currentStart != null) {
|
|
69
|
+
const realStart = unshiftFromDisplay(currentStart, oldTz);
|
|
70
|
+
this._startDate.set(shiftForDisplay(realStart, iana));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const currentEnd = this._endDate.get();
|
|
74
|
+
if (currentEnd != null) {
|
|
75
|
+
const realEnd = unshiftFromDisplay(currentEnd, oldTz);
|
|
76
|
+
this._endDate.set(shiftForDisplay(realEnd, iana));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this._timezone.set(iana);
|
|
80
|
+
this._isSelectOpen.set(false);
|
|
81
|
+
onTimezoneChange?.(iana);
|
|
82
|
+
}
|
|
83
|
+
}
|