Skip to content

Commit 0f10d44

Browse files
authored
feat(RangeDateSelection): add new component (#132)
1 parent 52e2952 commit 0f10d44

30 files changed

+2193
-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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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 type {ViewportDimensions, ViewportInterval} from './components/Ruler/Ruler';
15+
import {SelectionControl} from './components/SelectionControl/SelectionControl';
16+
import {useRangeDateSelectionState} from './hooks/useRangeDateSelectionState';
17+
import type {RangeDateSelectionOptions} from './hooks/useRangeDateSelectionState';
18+
import {i18n} from './i18n';
19+
20+
import './RangeDateSelection.scss';
21+
22+
const b = block('range-date-selection');
23+
24+
export interface RangeDateSelectionProps
25+
extends RangeDateSelectionOptions,
26+
DomProps,
27+
StyleProps,
28+
AccessibilityProps {
29+
/** Formats time ticks */
30+
formatTime?: (time: DateTime) => string;
31+
/** Displays now line */
32+
displayNow?: boolean;
33+
/** Enables dragging ruler */
34+
draggableRuler?: boolean;
35+
/** Displays buttons to scale selection */
36+
hasScaleButtons?: boolean;
37+
/** Position of scale buttons */
38+
scaleButtonsPosition?: 'start' | 'end';
39+
/** Renders additional svg content in the ruler */
40+
renderAdditionalRulerContent?: (props: {
41+
interval: ViewportInterval;
42+
dimensions: ViewportDimensions;
43+
}) => React.ReactNode;
44+
}
45+
46+
export function RangeDateSelection(props: RangeDateSelectionProps) {
47+
const state = useRangeDateSelectionState(props);
48+
49+
const [isDraggingRuler, setDraggingRuler] = React.useState(false);
50+
51+
const handleRulerMoveStart = () => {
52+
state.setDraggingValue(state.value);
53+
setDraggingRuler(true);
54+
};
55+
const handleRulerMove = (d: number) => {
56+
const intervalWidth = state.viewportInterval.end.diff(state.viewportInterval.start);
57+
const delta = -Math.floor((d * intervalWidth) / 100);
58+
state.move(delta);
59+
};
60+
const handleRulerMoveEnd = () => {
61+
setDraggingRuler(false);
62+
state.endDragging();
63+
};
64+
65+
let id = React.useId();
66+
id = props.id ?? id;
67+
68+
return (
69+
<div
70+
{...filterDOMProps(props, {labelable: true})}
71+
id={id}
72+
className={b(null, props.className)}
73+
style={props.style}
74+
dir="ltr" // TODO: RTL support
75+
>
76+
<DateTimeRuler
77+
className={b('ruler', {dragging: isDraggingRuler})}
78+
{...state.viewportInterval}
79+
onMoveStart={handleRulerMoveStart}
80+
onMove={props.draggableRuler ? handleRulerMove : undefined}
81+
onMoveEnd={handleRulerMoveEnd}
82+
dragDisabled={state.isDragging}
83+
displayNow={props.displayNow}
84+
minValue={props.minValue}
85+
maxValue={props.maxValue}
86+
formatTime={props.formatTime}
87+
timeZone={state.timeZone}
88+
renderAdditionalRulerContent={props.renderAdditionalRulerContent}
89+
>
90+
<SelectionControl className={b('selection')} state={state} aria-labelledby={id} />
91+
</DateTimeRuler>
92+
{props.hasScaleButtons ? (
93+
<div className={b('buttons', {position: props.scaleButtonsPosition ?? 'start'})}>
94+
<Button
95+
view="flat-secondary"
96+
size="xs"
97+
onClick={() => {
98+
state.startDragging();
99+
state.scale(0.5);
100+
state.endDragging();
101+
}}
102+
extraProps={{'aria-label': i18n('Decrease range')}}
103+
>
104+
<Icon data={Minus} />
105+
</Button>
106+
<Button
107+
view="flat-secondary"
108+
size="xs"
109+
onClick={() => {
110+
state.startDragging();
111+
state.scale(1.5);
112+
state.endDragging();
113+
}}
114+
extraProps={{'aria-label': i18n('Increase range')}}
115+
>
116+
<Icon data={Plus} />
117+
</Button>
118+
</div>
119+
) : null}
120+
</div>
121+
);
122+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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+
import type {ViewportDimensions, ViewportInterval} from '../components/Ruler/Ruler';
14+
15+
const meta: Meta<typeof RangeDateSelection> = {
16+
title: 'Components/RangeDateSelection',
17+
component: RangeDateSelection,
18+
tags: ['autodocs'],
19+
args: {
20+
onUpdate: action('onUpdate'),
21+
},
22+
};
23+
24+
export default meta;
25+
26+
type Story = StoryObj<typeof RangeDateSelection>;
27+
28+
export const Default = {
29+
render: (args) => {
30+
const timeZone = args.timeZone;
31+
const props = {
32+
...args,
33+
minValue: args.minValue ? dateTimeParse(args.minValue, {timeZone}) : undefined,
34+
maxValue: args.maxValue ? dateTimeParse(args.maxValue, {timeZone}) : undefined,
35+
placeholderValue: args.placeholderValue
36+
? dateTimeParse(args.placeholderValue, {timeZone})
37+
: undefined,
38+
renderAdditionalRulerContent:
39+
(args.renderAdditionalRulerContent as unknown as string) === 'fill'
40+
? renderAdditionalRulerContent
41+
: undefined,
42+
};
43+
return <RangeDateSelection {...props} />;
44+
},
45+
argTypes: {
46+
minValue: {
47+
control: {
48+
type: 'text',
49+
},
50+
},
51+
maxValue: {
52+
control: {
53+
type: 'text',
54+
},
55+
},
56+
placeholderValue: {
57+
control: {
58+
type: 'text',
59+
},
60+
},
61+
timeZone: timeZoneControl,
62+
renderAdditionalRulerContent: {
63+
options: ['none', 'fill'],
64+
control: {
65+
type: 'radio',
66+
},
67+
},
68+
},
69+
} satisfies Story;
70+
71+
export const WithControls = {
72+
...Default,
73+
render: function WithControls(args) {
74+
const timeZone = args.timeZone;
75+
const minValue = args.minValue ? dateTimeParse(args.minValue, {timeZone}) : undefined;
76+
const maxValue = args.maxValue ? dateTimeParse(args.maxValue, {timeZone}) : undefined;
77+
const placeholderValue = args.placeholderValue
78+
? dateTimeParse(args.placeholderValue, {timeZone})
79+
: undefined;
80+
81+
const [value, setValue] = React.useState<RelativeRangeDatePickerValue>({
82+
start: {
83+
type: 'relative',
84+
value: 'now - 1d',
85+
},
86+
end: {
87+
type: 'relative',
88+
value: 'now',
89+
},
90+
});
91+
92+
const {start, end} = toAbsoluteRange(value, timeZone);
93+
94+
const [, rerender] = React.useState({});
95+
React.useEffect(() => {
96+
const hasRelative = value.start?.type === 'relative' || value.end?.type === 'relative';
97+
if (hasRelative) {
98+
const timer = setInterval(() => {
99+
rerender({});
100+
}, 1000);
101+
return () => clearInterval(timer);
102+
}
103+
return undefined;
104+
}, [value]);
105+
106+
return (
107+
<div>
108+
<div
109+
style={{
110+
display: 'flex',
111+
gap: '1rem',
112+
justifyContent: 'flex-end',
113+
paddingBlock: '1rem',
114+
}}
115+
>
116+
<RelativeRangeDatePicker
117+
style={{width: '20rem'}}
118+
value={value}
119+
onUpdate={(v) => {
120+
if (v) {
121+
setValue(v);
122+
}
123+
}}
124+
format="L LTS"
125+
withApplyButton
126+
withPresets
127+
minValue={minValue}
128+
maxValue={maxValue}
129+
placeholderValue={placeholderValue}
130+
/>
131+
<div style={{display: 'flex', gap: '2px'}}>
132+
<Button
133+
view="flat"
134+
onClick={() => setValue(getRelativeInterval('now - 30m', 'now'))}
135+
>
136+
30m
137+
</Button>
138+
<Button
139+
view="flat"
140+
onClick={() => setValue(getRelativeInterval('now - 1h', 'now'))}
141+
>
142+
1h
143+
</Button>
144+
<Button
145+
view="flat"
146+
onClick={() => setValue(getRelativeInterval('now - 1d', 'now'))}
147+
>
148+
1d
149+
</Button>
150+
<Button
151+
view="flat"
152+
onClick={() => setValue(getRelativeInterval('now - 1w', 'now'))}
153+
>
154+
1w
155+
</Button>
156+
</div>
157+
</div>
158+
<RangeDateSelection
159+
{...args}
160+
value={{start, end}}
161+
onUpdate={(value) => {
162+
setValue({
163+
start: {type: 'absolute', value: value.start},
164+
end: {type: 'absolute', value: value.end},
165+
});
166+
}}
167+
minValue={minValue}
168+
maxValue={maxValue}
169+
placeholderValue={placeholderValue}
170+
/>
171+
</div>
172+
);
173+
},
174+
} satisfies Story;
175+
176+
function getRelativeInterval(start: string, end: string): RelativeRangeDatePickerValue {
177+
return {
178+
start: {type: 'relative', value: start},
179+
end: {type: 'relative', value: end},
180+
};
181+
}
182+
183+
function toAbsoluteRange(interval: RelativeRangeDatePickerValue, timeZone?: string) {
184+
const start: DateTime =
185+
interval.start?.type === 'relative'
186+
? dateTimeParse(interval.start.value, {timeZone})!
187+
: interval.start!.value;
188+
189+
const end: DateTime =
190+
interval.end?.type === 'relative'
191+
? dateTimeParse(interval.end.value, {roundUp: true, timeZone})!
192+
: interval.end!.value;
193+
194+
return {start, end};
195+
}
196+
197+
function renderAdditionalRulerContent(props: {
198+
interval: ViewportInterval;
199+
dimensions: ViewportDimensions;
200+
}) {
201+
const {width, height} = props.dimensions;
202+
return (
203+
<React.Fragment>
204+
{Array.from({length: 12}, (_, i) => (
205+
<path
206+
key={i}
207+
d={`M${(i * width) / 12},0l${width / 12},0l0,${height}l${-width / 12},0`}
208+
fill={`hsl(${i * 30}, 100%, 50%)`}
209+
fillOpacity={0.05}
210+
/>
211+
))}
212+
</React.Fragment>
213+
);
214+
}
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)