@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.
Files changed (63) hide show
  1. package/dist/components/date_range_panel/date_range_panel.d.ts +13 -0
  2. package/dist/components/date_range_panel/date_range_panel.d.ts.map +1 -0
  3. package/dist/components/date_range_panel/date_range_panel.js +71 -0
  4. package/dist/components/date_range_panel/date_range_panel.js.map +1 -0
  5. package/dist/components/date_range_panel/date_range_panel_presenter.d.ts +14 -0
  6. package/dist/components/date_range_panel/date_range_panel_presenter.d.ts.map +1 -0
  7. package/dist/components/date_range_panel/date_range_panel_presenter.js +24 -0
  8. package/dist/components/date_range_panel/date_range_panel_presenter.js.map +1 -0
  9. package/dist/components/preset_panel/preset_panel.d.ts +11 -0
  10. package/dist/components/preset_panel/preset_panel.d.ts.map +1 -0
  11. package/dist/components/preset_panel/preset_panel.js +52 -0
  12. package/dist/components/preset_panel/preset_panel.js.map +1 -0
  13. package/dist/components/preset_panel/preset_panel_presenter.d.ts +24 -0
  14. package/dist/components/preset_panel/preset_panel_presenter.d.ts.map +1 -0
  15. package/dist/components/preset_panel/preset_panel_presenter.js +39 -0
  16. package/dist/components/preset_panel/preset_panel_presenter.js.map +1 -0
  17. package/dist/components/time_selector/time_selector.d.ts +12 -0
  18. package/dist/components/time_selector/time_selector.d.ts.map +1 -0
  19. package/dist/components/time_selector/time_selector.js +105 -0
  20. package/dist/components/time_selector/time_selector.js.map +1 -0
  21. package/dist/components/time_selector/time_selector_presenter.d.ts +28 -0
  22. package/dist/components/time_selector/time_selector_presenter.d.ts.map +1 -0
  23. package/dist/components/time_selector/time_selector_presenter.js +66 -0
  24. package/dist/components/time_selector/time_selector_presenter.js.map +1 -0
  25. package/dist/components/timezone_footer/timezone_footer.d.ts +9 -0
  26. package/dist/components/timezone_footer/timezone_footer.d.ts.map +1 -0
  27. package/dist/components/timezone_footer/timezone_footer.js +78 -0
  28. package/dist/components/timezone_footer/timezone_footer.js.map +1 -0
  29. package/dist/components/timezone_footer/timezone_footer_presenter.d.ts +23 -0
  30. package/dist/components/timezone_footer/timezone_footer_presenter.d.ts.map +1 -0
  31. package/dist/components/timezone_footer/timezone_footer_presenter.js +56 -0
  32. package/dist/components/timezone_footer/timezone_footer_presenter.js.map +1 -0
  33. package/dist/components/utils.d.ts +32 -0
  34. package/dist/components/utils.d.ts.map +1 -0
  35. package/dist/components/utils.js +62 -0
  36. package/dist/components/utils.js.map +1 -0
  37. package/dist/date_range_panel.css +1 -0
  38. package/dist/index.d.ts +3 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +5 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/preset_panel.css +1 -0
  43. package/dist/time_selector.css +1 -0
  44. package/dist/timezone_footer.css +1 -0
  45. package/package.json +72 -0
  46. package/src/__stories__/time_selector.stories.tsx +99 -0
  47. package/src/components/date_range_panel/date_range_panel.module.css +31 -0
  48. package/src/components/date_range_panel/date_range_panel.tsx +87 -0
  49. package/src/components/date_range_panel/date_range_panel_presenter.ts +35 -0
  50. package/src/components/preset_panel/preset_panel.module.css +35 -0
  51. package/src/components/preset_panel/preset_panel.tsx +74 -0
  52. package/src/components/preset_panel/preset_panel_presenter.ts +68 -0
  53. package/src/components/time_selector/time_selector.module.css +27 -0
  54. package/src/components/time_selector/time_selector.tsx +100 -0
  55. package/src/components/time_selector/time_selector_presenter.ts +116 -0
  56. package/src/components/timezone_footer/timezone_footer.module.css +28 -0
  57. package/src/components/timezone_footer/timezone_footer.tsx +109 -0
  58. package/src/components/timezone_footer/timezone_footer_presenter.ts +83 -0
  59. package/src/components/utils.ts +95 -0
  60. package/src/index.ts +5 -0
  61. package/tsconfig.json +7 -0
  62. package/types/file_types.d.ts +54 -0
  63. 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
+ }