Skip to content

Commit 7a34891

Browse files
acstllcursoragentclaudeelasticmachinetkajtoch
authored
[Shared UX][DateRangePicker] Enhance selectable text parts on button display (#270562)
## Summary Adds a UX feature to `DateRangePicker` (currently in Discover and Dashboards): makes each meaningful part of the text in the button be "clickable". Clicking a part automatically selects the corresponding part in the input control when focused. ### Before https://github.com/user-attachments/assets/9c401201-bf24-4bd8-a127-8391dc075fee ### After https://github.com/user-attachments/assets/a62b3513-ba85-4cb8-a613-8436b5d61286 Closes elastic/eui-private#672 ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. ### Identify risks No risks identified. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Tomasz Kajtoch <1282498+tkajtoch@users.noreply.github.com>
1 parent da1adac commit 7a34891

12 files changed

Lines changed: 896 additions & 36 deletions

File tree

src/platform/packages/shared/shared-ux/datetime/kbn-date-range-picker/__snapshots__/date_range_picker.test.tsx.snap

Lines changed: 27 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/platform/packages/shared/shared-ux/datetime/kbn-date-range-picker/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export const DATE_TYPE_RELATIVE = 'RELATIVE' as const;
1616
/** Date type representing the current moment ("now") */
1717
export const DATE_TYPE_NOW = 'NOW' as const;
1818

19+
/** Keyword users type to refer to the current moment in input and display text. */
20+
export const NOW_KEYWORD = 'now';
21+
1922
/** Default Moment.js format for displaying dates at full precision (e.g. "Feb 3, 2025, 14:30:07.801") */
2023
export const DEFAULT_DATE_FORMAT = 'MMM D, YYYY, HH:mm:ss.SSS';
2124

src/platform/packages/shared/shared-ux/datetime/kbn-date-range-picker/date_range_picker_control.test.tsx

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import React, { useState } from 'react';
11+
import moment from 'moment-timezone';
1112
import { fireEvent, render, screen, waitFor, act, within } from '@testing-library/react';
1213
import { renderWithEuiTheme } from '@kbn/test-jest-helpers';
1314
import { EuiThemeProvider } from '@elastic/eui';
@@ -34,6 +35,15 @@ const waitForPopoverClose = () =>
3435
});
3536

3637
describe('DateRangePickerControl', () => {
38+
// Pin to UTC so date formatting is deterministic across local machines and CI agents.
39+
beforeEach(() => {
40+
moment.tz.setDefault('UTC');
41+
});
42+
43+
afterEach(() => {
44+
moment.tz.setDefault('Browser');
45+
});
46+
3747
describe('editing mode', () => {
3848
it('enters editing mode on control button click', async () => {
3949
renderWithEuiTheme(<DateRangePicker {...defaultProps} onChange={() => {}} />);
@@ -50,6 +60,79 @@ describe('DateRangePickerControl', () => {
5060
await waitForPopoverClose();
5161
});
5262

63+
it('selects the clicked display part in the input', async () => {
64+
renderWithEuiTheme(<DateRangePicker {...defaultProps} onChange={() => {}} />);
65+
66+
const displayPart = screen.getByText('20');
67+
fireEvent.mouseDown(displayPart);
68+
fireEvent.click(displayPart);
69+
70+
const input = await screen.findByTestId('dateRangePickerInput');
71+
await waitFor(() => {
72+
expect(input).toHaveFocus();
73+
expect((input as HTMLInputElement).selectionStart).toBe(5);
74+
expect((input as HTMLInputElement).selectionEnd).toBe(7);
75+
});
76+
77+
fireEvent.keyDown(input, { key: 'Escape' });
78+
await waitForPopoverClose();
79+
});
80+
81+
it('keeps the clicked display part visible when the input is scrolled', async () => {
82+
const animationFrameCallbacks: FrameRequestCallback[] = [];
83+
const requestAnimationFrameSpy = jest
84+
.spyOn(window, 'requestAnimationFrame')
85+
.mockImplementation((callback) => {
86+
animationFrameCallbacks.push(callback);
87+
return animationFrameCallbacks.length;
88+
});
89+
const cancelAnimationFrameSpy = jest
90+
.spyOn(window, 'cancelAnimationFrame')
91+
.mockImplementation(() => {});
92+
const getContextSpy = jest
93+
.spyOn(HTMLCanvasElement.prototype, 'getContext')
94+
.mockReturnValue(null);
95+
96+
try {
97+
renderWithEuiTheme(
98+
<DateRangePicker
99+
{...defaultProps}
100+
defaultValue="2024-01-01T00:00:00.000Z to 2024-12-31T23:59:59.999Z"
101+
onChange={() => {}}
102+
/>
103+
);
104+
105+
// Both sides of the range render "2024"; pick the start-side year, matching the selection assertion below.
106+
const displayPart = screen.getAllByText('2024')[0];
107+
fireEvent.mouseDown(displayPart);
108+
fireEvent.click(displayPart);
109+
110+
const input = (await screen.findByTestId('dateRangePickerInput')) as HTMLInputElement;
111+
await waitFor(() => {
112+
expect(input.selectionStart).toBe(7);
113+
expect(input.selectionEnd).toBe(11);
114+
});
115+
Object.defineProperty(input, 'clientWidth', { configurable: true, value: 80 });
116+
Object.defineProperty(input, 'scrollWidth', { configurable: true, value: 800 });
117+
input.scrollLeft = 720;
118+
119+
act(() => {
120+
for (const callback of animationFrameCallbacks) {
121+
callback(performance.now());
122+
}
123+
});
124+
125+
expect(input.scrollLeft).toBeLessThan(200);
126+
127+
fireEvent.keyDown(input, { key: 'Escape' });
128+
await waitForPopoverClose();
129+
} finally {
130+
requestAnimationFrameSpy.mockRestore();
131+
cancelAnimationFrameSpy.mockRestore();
132+
getContextSpy.mockRestore();
133+
}
134+
});
135+
53136
it('submits on Enter and returns to idle mode', async () => {
54137
const onChange = jest.fn();
55138
renderWithEuiTheme(<DateRangePicker {...defaultProps} onChange={onChange} />);
@@ -303,7 +386,11 @@ describe('DateRangePickerControl', () => {
303386
'Last 20 minutes'
304387
);
305388

306-
rerender(<DateRangePicker value="last 1 hour" onChange={() => {}} {...controlledDefaults} />);
389+
rerender(
390+
<EuiThemeProvider>
391+
<DateRangePicker value="last 1 hour" onChange={() => {}} {...controlledDefaults} />
392+
</EuiThemeProvider>
393+
);
307394
await waitFor(() => {
308395
expect(screen.getByTestId('dateRangePickerControlButton')).toHaveTextContent('Last 1 hour');
309396
});
@@ -319,7 +406,11 @@ describe('DateRangePickerControl', () => {
319406
fireEvent.change(input, { target: { value: 'last 5 minutes' } });
320407
expect(input).toHaveValue('last 5 minutes');
321408

322-
rerender(<DateRangePicker value="last 1 hour" onChange={() => {}} {...controlledDefaults} />);
409+
rerender(
410+
<EuiThemeProvider>
411+
<DateRangePicker value="last 1 hour" onChange={() => {}} {...controlledDefaults} />
412+
</EuiThemeProvider>
413+
);
323414
await waitFor(() => {
324415
expect(input).toHaveValue('last 5 minutes');
325416
});
@@ -339,7 +430,9 @@ describe('DateRangePickerControl', () => {
339430

340431
await act(async () =>
341432
rerender(
342-
<DateRangePicker value="last 1 hour" onChange={() => {}} {...controlledDefaults} />
433+
<EuiThemeProvider>
434+
<DateRangePicker value="last 1 hour" onChange={() => {}} {...controlledDefaults} />
435+
</EuiThemeProvider>
343436
)
344437
);
345438
fireEvent.keyDown(input, { key: 'Escape' });

src/platform/packages/shared/shared-ux/datetime/kbn-date-range-picker/date_range_picker_control.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,20 @@ import {
2121
} from '@elastic/eui';
2222

2323
import { FOCUSABLE_SELECTOR } from './constants';
24-
import { isRelativeToNow, resolveInitialFocus } from './utils';
24+
import {
25+
findCorrespondingInputPart,
26+
getInputScrollLeftToCenter,
27+
isRelativeToNow,
28+
resolveInitialFocus,
29+
} from './utils';
2530
import { DateRangePickerAutoRefreshButton } from './date_range_picker_auto_refresh_button';
2631
import { useDateRangePickerContext } from './date_range_picker_context';
2732
import { useSelectTextPartsWithArrowKeys } from './hooks/use_select_text_parts_with_arrow_keys';
2833
import { useInputHintText } from './hooks/use_input_hint_text';
2934
import { inputControlTexts } from './translations';
35+
import { DateRangeValueDisplay } from './date_range_value_display';
36+
import type { RangePart } from './parse/parse_range_parts';
37+
import { parseDisplayParts, parseInputParts } from './parse/parse_range_parts';
3038

3139
/**
3240
* The control portion of the DateRangePicker: displays a button when idle
@@ -67,6 +75,7 @@ export function DateRangePickerControl() {
6775
const controlRef = useRef<HTMLDivElement>(null);
6876
const wasEditingRef = useRef(false);
6977
const wasClearedRef = useRef(false);
78+
const clickedDisplayPartRef = useRef<RangePart | null>(null);
7079

7180
/** Focus the button when transitioning from editing to idle. */
7281
useEffect(() => {
@@ -101,6 +110,35 @@ export function DateRangePickerControl() {
101110
setIsEditing(true);
102111
};
103112

113+
const handleDisplayPartClick = (part: RangePart) => {
114+
clickedDisplayPartRef.current = part;
115+
};
116+
117+
/** Handle selecting specific parts when focusing the input */
118+
useEffect(() => {
119+
if (!isEditing || !inputRef.current) return;
120+
121+
const clickedPart = clickedDisplayPartRef.current;
122+
clickedDisplayPartRef.current = null;
123+
124+
if (!clickedPart) return;
125+
126+
const inputParts = parseInputParts(text).filter((part) => part.navigable);
127+
const displayParts = parseDisplayParts(displayText);
128+
const target = findCorrespondingInputPart(inputParts, clickedPart, displayParts);
129+
130+
if (target) {
131+
const input = inputRef.current;
132+
// Optimistic `setSelectionRange` call, might remove after testing is not needed
133+
input.setSelectionRange(target.start, target.end);
134+
const requestId = requestAnimationFrame(() => {
135+
input.setSelectionRange(target.start, target.end);
136+
input.scrollLeft = getInputScrollLeftToCenter(input, target.start, target.end);
137+
});
138+
return () => cancelAnimationFrame(requestId);
139+
}
140+
}, [displayText, inputRef, isEditing, text]);
141+
104142
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
105143
const nextValue = event.target.value;
106144
if (nextValue === '') wasClearedRef.current = true;
@@ -170,6 +208,12 @@ export function DateRangePickerControl() {
170208
const tooltipStyles = css`
171209
max-inline-size: min(58ch, 90vw);
172210
`;
211+
const inputStyles = css`
212+
&::selection {
213+
color: ${euiTheme.colors.textPrimary};
214+
background-color: ${euiTheme.colors.backgroundLightPrimary};
215+
}
216+
`;
173217

174218
return (
175219
<div
@@ -222,6 +266,7 @@ export function DateRangePickerControl() {
222266
onKeyDown={onInputKeyDown}
223267
compressed={compressed}
224268
placeholder={`${hintTextPrefix} "${hintText}"`}
269+
css={inputStyles}
225270
/>
226271
) : (
227272
<EuiToolTip
@@ -235,16 +280,34 @@ export function DateRangePickerControl() {
235280
offset={euiTheme.base * 0.75}
236281
>
237282
<EuiFormControlButton
283+
css={css`
284+
/* TODO super fragile selector, we need to find out why
285+
is this <span> there at all in EuiFormControlButton */
286+
.euiButtonEmpty__content > span {
287+
display: flex;
288+
flex-grow: 1;
289+
align-items: center;
290+
justify-content: space-between;
291+
gap: ${euiTheme.size.s};
292+
max-inline-size: 100%;
293+
}
294+
`}
238295
data-test-subj="dateRangePickerControlButton"
239296
data-date-range={`${timeRange.start} to ${timeRange.end}`}
240297
buttonRef={buttonRef}
241298
aria-label={collapsed ? displayText : undefined}
242-
value={collapsed ? undefined : displayText}
243299
onClick={onButtonClick}
244300
isInvalid={isInvalid}
245301
disabled={disabled}
246302
compressed={compressed}
247303
>
304+
{!collapsed && (
305+
<DateRangeValueDisplay
306+
displayText={displayText}
307+
onPartClick={handleDisplayPartClick}
308+
disabled={disabled}
309+
/>
310+
)}
248311
{!hideBadge && (
249312
<EuiBadge data-test-subj="dateRangePickerDurationBadge">
250313
{displayShortDuration ?? '--'}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { css } from '@emotion/react';
11+
import type { UseEuiTheme } from '@elastic/eui';
12+
13+
const dateRangeValueDisplayStyles = ({ euiTheme }: UseEuiTheme) => ({
14+
root: css`
15+
white-space: nowrap;
16+
`,
17+
part: css`
18+
@media (hover: hover) and (pointer: fine) {
19+
&:hover {
20+
color: ${euiTheme.colors.textPrimary};
21+
background-color: ${euiTheme.colors.backgroundLightPrimary};
22+
}
23+
}
24+
`,
25+
});
26+
27+
export { dateRangeValueDisplayStyles };

0 commit comments

Comments
 (0)