Skip to content

Commit 9743775

Browse files
committed
feat(RangeDateSelection): add new component
1 parent 3fa29eb commit 9743775

28 files changed

+2121
-0
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
@use '../variables';
2+
@use '../mixins';
3+
4+
$block: '.#{variables.$ns}range-date-selection';
5+
6+
#{$block} {
7+
display: grid;
8+
align-items: center;
9+
grid-template-areas: 'buttons-start ruler buttons-end';
10+
grid-template-columns: auto 1fr auto;
11+
12+
border-block: 1px solid var(--g-color-line-generic);
13+
14+
&__ruler {
15+
grid-area: ruler;
16+
17+
&_dragging #{$block}__selection {
18+
pointer-events: none;
19+
}
20+
}
21+
22+
&__buttons {
23+
display: flex;
24+
align-items: center;
25+
26+
height: 22px;
27+
28+
&_position_start {
29+
grid-area: buttons-start;
30+
31+
padding-inline-end: var(--g-spacing-half);
32+
33+
border-inline-end: 1px solid var(--g-color-line-generic);
34+
}
35+
36+
&_position_end {
37+
grid-area: buttons-end;
38+
39+
padding-inline-start: var(--g-spacing-half);
40+
41+
border-inline-start: 1px solid var(--g-color-line-generic);
42+
}
43+
}
44+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
'use client';
2+
3+
import React from 'react';
4+
5+
import type {DateTime} from '@gravity-ui/date-utils';
6+
import {Minus, Plus} from '@gravity-ui/icons';
7+
import {Button, Icon} from '@gravity-ui/uikit';
8+
9+
import {block} from '../../utils/cn';
10+
import type {AccessibilityProps, DomProps, StyleProps} from '../types';
11+
import {filterDOMProps} from '../utils/filterDOMProps';
12+
13+
import {DateTimeRuler} from './components/Ruler/Ruler';
14+
import {SelectionControl} from './components/SelectionControl/SelectionControl';
15+
import {useRangeDateSelectionState} from './hooks/useRangeDateSelectionState';
16+
import type {RangeDateSelectionOptions} from './hooks/useRangeDateSelectionState';
17+
import {i18n} from './i18n';
18+
19+
import './RangeDateSelection.scss';
20+
21+
const b = block('range-date-selection');
22+
23+
export interface RangeDateSelectionProps
24+
extends RangeDateSelectionOptions,
25+
DomProps,
26+
StyleProps,
27+
AccessibilityProps {
28+
/** Formats time ticks */
29+
formatTime?: (time: DateTime) => string;
30+
/** Displays now line */
31+
displayNow?: boolean;
32+
/** Enables dragging ruler */
33+
draggableRuler?: boolean;
34+
/** Displays buttons to scale selection */
35+
hasScaleButtons?: boolean;
36+
/** Position of scale buttons */
37+
scaleButtonsPosition?: 'start' | 'end';
38+
}
39+
40+
export function RangeDateSelection(props: RangeDateSelectionProps) {
41+
const state = useRangeDateSelectionState(props);
42+
43+
const [isDraggingRuler, setDraggingRuler] = React.useState(false);
44+
45+
const handleRulerMoveStart = () => {
46+
state.setDraggingValue(state.value);
47+
setDraggingRuler(true);
48+
};
49+
const handleRulerMove = (d: number) => {
50+
const intervalWidth = state.viewportInterval.end.diff(state.viewportInterval.start);
51+
const delta = -Math.floor((d * intervalWidth) / 100);
52+
state.move(delta);
53+
};
54+
const handleRulerMoveEnd = () => {
55+
setDraggingRuler(false);
56+
state.endDragging();
57+
};
58+
59+
let id = React.useId();
60+
id = props.id ?? id;
61+
62+
return (
63+
<div
64+
{...filterDOMProps(props, {labelable: true})}
65+
id={id}
66+
className={b(null, props.className)}
67+
style={props.style}
68+
dir="ltr" // TODO: RTL support
69+
>
70+
<DateTimeRuler
71+
className={b('ruler', {dragging: isDraggingRuler})}
72+
{...state.viewportInterval}
73+
onMoveStart={handleRulerMoveStart}
74+
onMove={props.draggableRuler ? handleRulerMove : undefined}
75+
onMoveEnd={handleRulerMoveEnd}
76+
dragDisabled={state.isDragging}
77+
displayNow={props.displayNow}
78+
minValue={props.minValue}
79+
maxValue={props.maxValue}
80+
formatTime={props.formatTime}
81+
timeZone={state.timeZone}
82+
>
83+
<SelectionControl className={b('selection')} state={state} aria-labelledby={id} />
84+
</DateTimeRuler>
85+
{props.hasScaleButtons ? (
86+
<div className={b('buttons', {position: props.scaleButtonsPosition ?? 'start'})}>
87+
<Button
88+
view="flat-secondary"
89+
size="xs"
90+
onClick={() => {
91+
state.startDragging();
92+
state.scale(0.5);
93+
state.endDragging();
94+
}}
95+
extraProps={{'aria-label': i18n('Decrease range')}}
96+
>
97+
<Icon data={Minus} />
98+
</Button>
99+
<Button
100+
view="flat-secondary"
101+
size="xs"
102+
onClick={() => {
103+
state.startDragging();
104+
state.scale(1.5);
105+
state.endDragging();
106+
}}
107+
extraProps={{'aria-label': i18n('Increase range')}}
108+
>
109+
<Icon data={Plus} />
110+
</Button>
111+
</div>
112+
) : null}
113+
</div>
114+
);
115+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import React from 'react';
2+
3+
import {dateTimeParse} from '@gravity-ui/date-utils';
4+
import type {DateTime} from '@gravity-ui/date-utils';
5+
import {Button} from '@gravity-ui/uikit';
6+
import {action} from '@storybook/addon-actions';
7+
import type {Meta, StoryObj} from '@storybook/react';
8+
9+
import {timeZoneControl} from '../../../demo/utils/zones';
10+
import {RelativeRangeDatePicker} from '../../RelativeRangeDatePicker';
11+
import type {RelativeRangeDatePickerValue} from '../../RelativeRangeDatePicker';
12+
import {RangeDateSelection} from '../RangeDateSelection';
13+
14+
const meta: Meta<typeof RangeDateSelection> = {
15+
title: 'Components/RangeDateSelection',
16+
component: RangeDateSelection,
17+
tags: ['autodocs'],
18+
args: {
19+
onUpdate: action('onUpdate'),
20+
},
21+
};
22+
23+
export default meta;
24+
25+
type Story = StoryObj<typeof RangeDateSelection>;
26+
27+
export const Default = {
28+
render: (args) => {
29+
const timeZone = args.timeZone;
30+
const props = {
31+
...args,
32+
minValue: args.minValue ? dateTimeParse(args.minValue, {timeZone}) : undefined,
33+
maxValue: args.maxValue ? dateTimeParse(args.maxValue, {timeZone}) : undefined,
34+
placeholderValue: args.placeholderValue
35+
? dateTimeParse(args.placeholderValue, {timeZone})
36+
: undefined,
37+
};
38+
return <RangeDateSelection {...props} />;
39+
},
40+
argTypes: {
41+
minValue: {
42+
control: {
43+
type: 'text',
44+
},
45+
},
46+
maxValue: {
47+
control: {
48+
type: 'text',
49+
},
50+
},
51+
placeholderValue: {
52+
control: {
53+
type: 'text',
54+
},
55+
},
56+
timeZone: timeZoneControl,
57+
},
58+
} satisfies Story;
59+
60+
export const WithControls = {
61+
...Default,
62+
render: function WithControls(args) {
63+
const timeZone = args.timeZone;
64+
const minValue = args.minValue ? dateTimeParse(args.minValue, {timeZone}) : undefined;
65+
const maxValue = args.maxValue ? dateTimeParse(args.maxValue, {timeZone}) : undefined;
66+
const placeholderValue = args.placeholderValue
67+
? dateTimeParse(args.placeholderValue, {timeZone})
68+
: undefined;
69+
70+
const [value, setValue] = React.useState<RelativeRangeDatePickerValue>({
71+
start: {
72+
type: 'relative',
73+
value: 'now - 1d',
74+
},
75+
end: {
76+
type: 'relative',
77+
value: 'now',
78+
},
79+
});
80+
81+
const {start, end} = toAbsoluteRange(value, timeZone);
82+
83+
const [, rerender] = React.useState({});
84+
React.useEffect(() => {
85+
const hasRelative = value.start?.type === 'relative' || value.end?.type === 'relative';
86+
if (hasRelative) {
87+
const timer = setInterval(() => {
88+
rerender({});
89+
}, 1000);
90+
return () => clearInterval(timer);
91+
}
92+
return undefined;
93+
}, [value]);
94+
95+
return (
96+
<div>
97+
<div
98+
style={{
99+
display: 'flex',
100+
gap: '1rem',
101+
justifyContent: 'flex-end',
102+
paddingBlock: '1rem',
103+
}}
104+
>
105+
<RelativeRangeDatePicker
106+
style={{width: '20rem'}}
107+
value={value}
108+
onUpdate={(v) => {
109+
if (v) {
110+
setValue(v);
111+
}
112+
}}
113+
format="L LTS"
114+
withApplyButton
115+
withPresets
116+
minValue={minValue}
117+
maxValue={maxValue}
118+
placeholderValue={placeholderValue}
119+
/>
120+
<div style={{display: 'flex', gap: '2px'}}>
121+
<Button
122+
view="flat"
123+
onClick={() => setValue(getRelativeInterval('now - 30m', 'now'))}
124+
>
125+
30m
126+
</Button>
127+
<Button
128+
view="flat"
129+
onClick={() => setValue(getRelativeInterval('now - 1h', 'now'))}
130+
>
131+
1h
132+
</Button>
133+
<Button
134+
view="flat"
135+
onClick={() => setValue(getRelativeInterval('now - 1d', 'now'))}
136+
>
137+
1d
138+
</Button>
139+
<Button
140+
view="flat"
141+
onClick={() => setValue(getRelativeInterval('now - 1w', 'now'))}
142+
>
143+
1w
144+
</Button>
145+
</div>
146+
</div>
147+
<RangeDateSelection
148+
{...args}
149+
value={{start, end}}
150+
onUpdate={(value) => {
151+
setValue({
152+
start: {type: 'absolute', value: value.start},
153+
end: {type: 'absolute', value: value.end},
154+
});
155+
}}
156+
minValue={minValue}
157+
maxValue={maxValue}
158+
placeholderValue={placeholderValue}
159+
/>
160+
</div>
161+
);
162+
},
163+
} satisfies Story;
164+
165+
function getRelativeInterval(start: string, end: string): RelativeRangeDatePickerValue {
166+
return {
167+
start: {type: 'relative', value: start},
168+
end: {type: 'relative', value: end},
169+
};
170+
}
171+
172+
function toAbsoluteRange(interval: RelativeRangeDatePickerValue, timeZone?: string) {
173+
const start: DateTime =
174+
interval.start?.type === 'relative'
175+
? dateTimeParse(interval.start.value, {timeZone})!
176+
: interval.start!.value;
177+
178+
const end: DateTime =
179+
interval.end?.type === 'relative'
180+
? dateTimeParse(interval.end.value, {roundUp: true, timeZone})!
181+
: interval.end!.value;
182+
183+
return {start, end};
184+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@use '../../../variables';
2+
3+
$block: '.#{variables.$ns}timeline-now-line';
4+
5+
#{$block} {
6+
stroke: var(--g-date-thin-timeline-now-color);
7+
stroke-width: 2px;
8+
}

0 commit comments

Comments
 (0)