diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png
index d2984d4a0be..c8987f6687e 100644
Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png
new file mode 100644
index 00000000000..05ef003b1d3
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png
index 4a0009ba5d4..3544b765912 100644
Binary files a/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png and b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Quick_Select_Only.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png
new file mode 100644
index 00000000000..94bac6e6ed1
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiSuperDatePicker_EuiSuperDatePicker_Time_Window_Buttons.png differ
diff --git a/packages/eui/changelogs/upcoming/9151.md b/packages/eui/changelogs/upcoming/9151.md
new file mode 100644
index 00000000000..548300f184c
--- /dev/null
+++ b/packages/eui/changelogs/upcoming/9151.md
@@ -0,0 +1,2 @@
+- Updated `EuiSuperDatePicker` with new time window buttons for time shifting and zoom out, opt-in via `showTimeWindowButtons` boolean prop.
+
diff --git a/packages/eui/src/components/date_picker/super_date_picker/__snapshots__/time_window_buttons.test.tsx.snap b/packages/eui/src/components/date_picker/super_date_picker/__snapshots__/time_window_buttons.test.tsx.snap
new file mode 100644
index 00000000000..470d1f49ae9
--- /dev/null
+++ b/packages/eui/src/components/date_picker/super_date_picker/__snapshots__/time_window_buttons.test.tsx.snap
@@ -0,0 +1,87 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TimeWindowButtons renders 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/eui/src/components/date_picker/super_date_picker/pretty_duration.tsx b/packages/eui/src/components/date_picker/super_date_picker/pretty_duration.tsx
index 3f6f36d2ef0..494ac1ceb6f 100644
--- a/packages/eui/src/components/date_picker/super_date_picker/pretty_duration.tsx
+++ b/packages/eui/src/components/date_picker/super_date_picker/pretty_duration.tsx
@@ -11,7 +11,7 @@ import dateMath from '@elastic/datemath';
import moment, { LocaleSpecifier, RelativeTimeKey } from 'moment'; // eslint-disable-line import/named
import { useEuiI18n } from '../../i18n';
import { getDateMode, DATE_MODES } from './date_modes';
-import { parseRelativeParts } from './relative_utils';
+import { parseRelativeParts, isRelativeToNow } from './relative_utils';
import { useI18nTimeOptions } from './time_options';
import {
DurationRange,
@@ -315,16 +315,6 @@ const hasRangeMatch = (
return ranges.find(({ start, end }) => timeFrom === start && timeTo === end);
};
-const isRelativeToNow = (timeFrom: ShortDate, timeTo: ShortDate): boolean => {
- const fromDateMode = getDateMode(timeFrom);
- const toDateMode = getDateMode(timeTo);
- const isLast =
- fromDateMode === DATE_MODES.RELATIVE && toDateMode === DATE_MODES.NOW;
- const isNext =
- fromDateMode === DATE_MODES.NOW && toDateMode === DATE_MODES.RELATIVE;
- return isLast || isNext;
-};
-
export const showPrettyDuration = (
timeFrom: ShortDate,
timeTo: ShortDate,
diff --git a/packages/eui/src/components/date_picker/super_date_picker/relative_utils.ts b/packages/eui/src/components/date_picker/super_date_picker/relative_utils.ts
index 9ad0b211f6c..43bd15ef60e 100644
--- a/packages/eui/src/components/date_picker/super_date_picker/relative_utils.ts
+++ b/packages/eui/src/components/date_picker/super_date_picker/relative_utils.ts
@@ -11,7 +11,8 @@ import moment from 'moment';
import { get } from '../../../services/objects';
import { isString } from '../../../services/predicate';
-import { TimeUnitId, RelativeParts } from '../types';
+import { TimeUnitId, RelativeParts, ShortDate } from '../types';
+import { getDateMode, DATE_MODES } from './date_modes';
const ROUND_DELIMETER = '/';
@@ -80,3 +81,16 @@ export const toRelativeStringFromParts = (relativeParts: RelativeParts) => {
return `now${operator}${count}${unit}${round}`;
};
+
+export const isRelativeToNow = (
+ timeFrom: ShortDate,
+ timeTo: ShortDate
+): boolean => {
+ const fromDateMode = getDateMode(timeFrom);
+ const toDateMode = getDateMode(timeTo);
+ const isLast =
+ fromDateMode === DATE_MODES.RELATIVE && toDateMode === DATE_MODES.NOW;
+ const isNext =
+ fromDateMode === DATE_MODES.NOW && toDateMode === DATE_MODES.RELATIVE;
+ return isLast || isNext;
+};
diff --git a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.stories.tsx b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.stories.tsx
index 4168682c27f..d70ffe34b5c 100644
--- a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.stories.tsx
+++ b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.stories.tsx
@@ -190,6 +190,14 @@ export const QuickSelectOnly: Story = {
},
};
+export const TimeWindowButtons: Story = {
+ args: {
+ showTimeWindowButtons: true,
+ showUpdateButton: false,
+ },
+ render: (args) => ,
+};
+
/**
* VRT only
*/
diff --git a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.test.tsx b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.test.tsx
index 17f0e1e2485..dc72e4e0443 100644
--- a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.test.tsx
+++ b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.test.tsx
@@ -8,7 +8,7 @@
import React, { useState } from 'react';
import moment from 'moment';
-import { fireEvent, act } from '@testing-library/react';
+import { fireEvent, act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render, waitForEuiPopoverOpen, screen } from '../../../test/rtl';
@@ -19,6 +19,7 @@ import {
EuiSuperDatePicker,
EuiSuperDatePickerProps,
} from './super_date_picker';
+import { ZOOM_FACTOR_DEFAULT } from './time_window_buttons';
const noop = () => {};
@@ -640,4 +641,136 @@ describe('EuiSuperDatePicker', () => {
expect(prevEnd).toBe('2025-01-01T10:00:00.000Z');
});
});
+
+ describe('Time window buttons', () => {
+ it('renders only when showTimeWindowButtons prop is passed', () => {
+ const start = '2025-10-30T12:00:00.000Z';
+ const end = '2025-10-30T13:00:00.000Z';
+
+ const { queryByTestSubject, rerender } = render(
+ {}}
+ showTimeWindowButtons
+ />
+ );
+
+ expect(queryByTestSubject('timeWindowButtons')).toBeInTheDocument();
+
+ rerender(
+ {}} />
+ );
+
+ expect(queryByTestSubject('timeWindowButtons')).not.toBeInTheDocument();
+ });
+
+ it('updates time when shifting', async () => {
+ const start = '2025-10-30T12:00:00.000Z';
+ const end = '2025-10-30T13:00:00.000Z';
+ const stepBackwardStart = '2025-10-30T11:00:00.000Z';
+ const stepBackwardEnd = '2025-10-30T12:00:00.000Z';
+ let lastTimeChange: { start: string; end: string } = { start, end };
+
+ const { getByTestSubject } = render(
+ {
+ lastTimeChange = { start, end };
+ }}
+ showUpdateButton={false}
+ showTimeWindowButtons={true}
+ />
+ );
+
+ act(() => {
+ userEvent.click(getByTestSubject('timeWindowButtonsPrevious'));
+ });
+
+ await waitFor(() => {
+ expect(lastTimeChange.end).toEqual(stepBackwardEnd);
+ expect(lastTimeChange.start).toEqual(stepBackwardStart);
+
+ const initialTimeStart = new Date(start).getTime();
+ const updatedTimeStart = new Date(lastTimeChange.start).getTime();
+ const initialTimeEnd = new Date(end).getTime();
+ const updatedTimeEnd = new Date(lastTimeChange.end).getTime();
+
+ expect(initialTimeStart).toBeGreaterThan(updatedTimeStart);
+ expect(initialTimeEnd).toBeGreaterThan(updatedTimeEnd);
+ // Also check the diff is the same
+ expect(initialTimeEnd - initialTimeStart).toEqual(
+ updatedTimeEnd - updatedTimeStart
+ );
+ });
+ });
+
+ it('updates time when zooming out', async () => {
+ const start = '2025-10-30T12:00:00.000Z';
+ const end = '2025-10-31T12:00:00.000Z';
+ let lastTimeChange: { start: string; end: string } = { start, end };
+
+ const { getByTestSubject } = render(
+ {
+ lastTimeChange = { start, end };
+ }}
+ showUpdateButton={false}
+ showTimeWindowButtons={true}
+ />
+ );
+
+ act(() => {
+ userEvent.click(getByTestSubject('timeWindowButtonsZoomOut'));
+ });
+
+ await waitFor(() => {
+ const initialTimeStart = new Date(start).getTime();
+ const updatedTimeStart = new Date(lastTimeChange.start).getTime();
+ const initialTimeEnd = new Date(end).getTime();
+ const updatedTimeEnd = new Date(lastTimeChange.end).getTime();
+ expect(initialTimeStart).toBeGreaterThan(updatedTimeStart);
+ expect(initialTimeEnd).toBeLessThan(updatedTimeEnd);
+ // Check the diff expanded by zoom factor
+ expect(
+ (initialTimeEnd - initialTimeStart) * (1 + ZOOM_FACTOR_DEFAULT)
+ ).toEqual(updatedTimeEnd - updatedTimeStart);
+ });
+ });
+
+ it('is disabled when date/time range is invalid', async () => {
+ // reversed range (invalid)
+ const start = '2025-10-30T14:00:00.000Z';
+ const end = '2025-10-31T14:00:00.000Z';
+
+ const { rerender, getByTestSubject } = render(
+ {}}
+ showTimeWindowButtons={true}
+ />
+ );
+
+ expect(getByTestSubject('timeWindowButtonsPrevious')).toBeDisabled();
+ expect(getByTestSubject('timeWindowButtonsZoomOut')).toBeDisabled();
+ expect(getByTestSubject('timeWindowButtonsNext')).toBeDisabled();
+
+ rerender(
+ {}}
+ showTimeWindowButtons={true}
+ />
+ );
+
+ expect(getByTestSubject('timeWindowButtonsPrevious')).not.toBeDisabled();
+ expect(getByTestSubject('timeWindowButtonsZoomOut')).not.toBeDisabled();
+ expect(getByTestSubject('timeWindowButtonsNext')).not.toBeDisabled();
+ });
+ });
});
diff --git a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.tsx b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.tsx
index 46d20c15cf8..68e130e31d2 100644
--- a/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.tsx
+++ b/packages/eui/src/components/date_picker/super_date_picker/super_date_picker.tsx
@@ -39,6 +39,10 @@ import {
import { TimeOptions, RenderI18nTimeOptions } from './time_options';
import { PrettyDuration, showPrettyDuration } from './pretty_duration';
+import {
+ TimeWindowButtons,
+ type TimeWindowButtonsConfig,
+} from './time_window_buttons';
import { AsyncInterval } from './async_interval';
import {
@@ -202,6 +206,12 @@ export type EuiSuperDatePickerProps = CommonProps & {
*/
showUpdateButton?: boolean | 'iconOnly';
+ /**
+ * Set to true to display buttons for time shifting and zooming out,
+ * next to the top-level control.
+ */
+ showTimeWindowButtons?: boolean | TimeWindowButtonsConfig;
+
/**
* Hides the actual input reducing to just the quick select button.
*/
@@ -725,6 +735,27 @@ export class EuiSuperDatePickerInternal extends Component<
}
};
+ renderTimeWindowButtons = () => {
+ if (!this.props.showTimeWindowButtons || this.props.isAutoRefreshOnly) {
+ return null;
+ }
+ const { start, end, showTimeWindowButtons, compressed, isDisabled } =
+ this.props;
+ const config =
+ typeof showTimeWindowButtons === 'boolean' ? {} : showTimeWindowButtons;
+
+ return (
+
+ );
+ };
+
renderUpdateButton = () => {
const {
isLoading,
@@ -805,6 +836,7 @@ export class EuiSuperDatePickerInternal extends Component<
) : (
<>
{this.renderDatePickerRange()}
+ {this.renderTimeWindowButtons()}
{this.renderUpdateButton()}
>
)}
diff --git a/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx
new file mode 100644
index 00000000000..0be19f9c9e4
--- /dev/null
+++ b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx
@@ -0,0 +1,208 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+import React from 'react';
+import moment from 'moment';
+import { act, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import {
+ render,
+ renderHook,
+ renderHookAct,
+ waitForEuiToolTipVisible,
+} from '../../../test/rtl';
+
+import {
+ TimeWindowButtons,
+ useTimeWindow,
+ ZOOM_FACTOR_DEFAULT,
+} from './time_window_buttons';
+
+describe('TimeWindowButtons: useTimeWindow hook', () => {
+ describe('displayInterval', () => {
+ it('handles relative times', () => {
+ const applyTime = jest.fn();
+ const start = 'now-15m';
+ const end = 'now';
+
+ const { result } = renderHook(() => useTimeWindow(start, end, applyTime));
+
+ expect(result.current.displayInterval).toBe('15 minutes');
+ });
+
+ it('handles absolute times', () => {
+ const applyTime = jest.fn();
+ const start = '2025-10-29T16:00:00.000Z';
+ const end = '2025-10-29T16:15:00.000Z';
+
+ const { result } = renderHook(() => useTimeWindow(start, end, applyTime));
+
+ expect(result.current.displayInterval).toBe('15 minutes');
+ });
+
+ it('handles invalid time (undefined)', () => {
+ const applyTime = jest.fn();
+ const start = undefined;
+ const end = '2025-10-29T16:15:00.000Z';
+
+ // @ts-expect-error - intentionally testing with undefined start value
+ const { result } = renderHook(() => useTimeWindow(start, end, applyTime));
+
+ expect(result.current.displayInterval).toBe('');
+ expect(result.current.isInvalid).toBeTruthy();
+ });
+
+ it('handles invalid time', () => {
+ const applyTime = jest.fn();
+ const start = '2025-10-29T16:00:00.000Z';
+ const end = 'not a date';
+
+ const { result } = renderHook(() => useTimeWindow(start, end, applyTime));
+
+ expect(result.current.displayInterval).toBe('');
+ expect(result.current.isInvalid).toBeTruthy();
+ });
+
+ it('adds a tilde for approximate ranges', () => {
+ const applyTime = jest.fn();
+ const start = '2025-10-27T16:00:01.000Z';
+ const end = '2025-10-29T16:12:00.000Z';
+
+ const { result } = renderHook(() => useTimeWindow(start, end, applyTime));
+
+ expect(result.current.displayInterval).toBe('~2 days');
+ });
+ });
+
+ describe('stepForward callback', () => {
+ it('shifts time window forward', () => {
+ const applyTime = jest.fn();
+ const start = '2025-10-30T10:00:00.000Z';
+ const end = '2025-10-30T11:00:00.000Z';
+
+ const { result } = renderHook(() => useTimeWindow(start, end, applyTime));
+
+ renderHookAct(() => {
+ result.current.stepForward();
+ });
+
+ expect(applyTime).toHaveBeenCalledWith({
+ start: '2025-10-30T11:00:00.000Z',
+ end: '2025-10-30T12:00:00.000Z',
+ });
+ });
+ });
+
+ describe('stepBackward callback', () => {
+ it('shifts time window backward', () => {
+ const applyTime = jest.fn();
+ const start = '2025-10-30T10:00:00.000Z';
+ const end = '2025-10-30T11:00:00.000Z';
+
+ const { result } = renderHook(() => useTimeWindow(start, end, applyTime));
+
+ renderHookAct(() => {
+ result.current.stepBackward();
+ });
+
+ expect(applyTime).toHaveBeenCalledWith({
+ start: '2025-10-30T09:00:00.000Z',
+ end: '2025-10-30T10:00:00.000Z',
+ });
+ });
+ });
+
+ describe('expandWindow callback', () => {
+ it('expands time window on both ends of the range', () => {
+ const applyTime = jest.fn();
+ const start = '2025-10-30T10:00:00.000Z';
+ const end = '2025-10-30T11:00:00.000Z';
+
+ const shiftedStart = moment(start).subtract(
+ ZOOM_FACTOR_DEFAULT / 2,
+ 'hours'
+ );
+ const shiftedEnd = moment(end).add(ZOOM_FACTOR_DEFAULT / 2, 'hours');
+
+ const { result } = renderHook(() => useTimeWindow(start, end, applyTime));
+
+ renderHookAct(() => {
+ result.current.expandWindow();
+ });
+
+ expect(applyTime).toHaveBeenCalledWith({
+ start: shiftedStart.toISOString(),
+ end: shiftedEnd.toISOString(),
+ });
+ });
+
+ it('handles different zoom factor option', () => {
+ const customZoomFactor = 0.42;
+ const applyTime = jest.fn();
+ const start = '2025-10-30T10:00:00.000Z';
+ const end = '2025-10-30T11:00:00.000Z';
+
+ const shiftedStart = moment(start).subtract(
+ customZoomFactor / 2,
+ 'hours'
+ );
+ const shiftedEnd = moment(end).add(customZoomFactor / 2, 'hours');
+
+ const { result } = renderHook(() =>
+ useTimeWindow(start, end, applyTime, { zoomFactor: customZoomFactor })
+ );
+
+ renderHookAct(() => {
+ result.current.expandWindow();
+ });
+
+ expect(applyTime).toHaveBeenCalledWith({
+ start: shiftedStart.toISOString(),
+ end: shiftedEnd.toISOString(),
+ });
+ });
+ });
+});
+
+describe('TimeWindowButtons', () => {
+ it('renders', () => {
+ const start = 'now-15m';
+ const end = 'now';
+
+ const { container } = render(
+ {}} />
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ // This will not happen at all, because any invalid time range will toggle the buttons disabled,
+ // but we provide it in case requirements change
+ it('handles invalid times gracefully', async () => {
+ const apply = jest.fn();
+ const start = 'not a date';
+ const end = 'now';
+
+ const { getByTestSubject, getByRole } = render(
+
+ );
+
+ act(() => {
+ userEvent.click(getByTestSubject('timeWindowButtonsPrevious'));
+ userEvent.click(getByTestSubject('timeWindowButtonsZoomOut'));
+ userEvent.click(getByTestSubject('timeWindowButtonsNext'));
+ });
+
+ expect(apply).not.toHaveBeenCalled();
+
+ fireEvent.mouseEnter(getByTestSubject('timeWindowButtonsZoomOut'));
+ await waitForEuiToolTipVisible();
+
+ expect(getByRole('tooltip')).toHaveTextContent(/Cannot/);
+ });
+});
diff --git a/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.tsx b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.tsx
new file mode 100644
index 00000000000..38e0a2e68ba
--- /dev/null
+++ b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.tsx
@@ -0,0 +1,268 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import dateMath from '@elastic/datemath';
+import moment from 'moment';
+
+import { ShortDate, ApplyTime } from '../types';
+import { usePrettyInterval } from './pretty_interval';
+import { isRelativeToNow } from './relative_utils';
+
+import { EuiButtonGroupButton } from '../../button/button_group/button_group_button';
+import { euiButtonGroupButtonsStyles } from '../../button/button_group/button_group.styles';
+import { useEuiMemoizedStyles, useGeneratedHtmlId } from '../../../services';
+import { useEuiI18n } from '../../i18n';
+
+export const ZOOM_FACTOR_DEFAULT = 0.5;
+
+export interface TimeWindowButtonsConfig {
+ /**
+ * Show button for zooming out
+ * @default true
+ */
+ showZoomOut?: boolean;
+ /**
+ * Show buttons for shifting the time window forward and backward
+ * @default true
+ */
+ showShiftArrows?: boolean;
+ /**
+ * How much the time window is increased when zooming.
+ * A number between 0 and 1 e.g. 0.25, or a string representing a percentage e.g. 25%
+ * @default 0.5
+ * */
+ zoomFactor?: number | string;
+}
+
+export type TimeWindowButtonsProps = TimeWindowButtonsConfig & {
+ applyTime: ApplyTime;
+ start: ShortDate;
+ end: ShortDate;
+ compressed?: boolean;
+ isDisabled?: boolean;
+};
+
+/**
+ * Button group with time window controls for shifting the time window
+ * forwards and backwards, and zooming out.
+ */
+export const TimeWindowButtons: React.FC = ({
+ applyTime,
+ start,
+ end,
+ compressed,
+ isDisabled,
+ showZoomOut = true,
+ showShiftArrows = true,
+ zoomFactor = ZOOM_FACTOR_DEFAULT,
+}) => {
+ const buttonColor = 'text';
+ const buttonSize = compressed ? 's' : 'm';
+ const iconSize = compressed ? 's' : 'm';
+ const styles = useEuiMemoizedStyles(euiButtonGroupButtonsStyles);
+
+ const {
+ displayInterval,
+ isInvalid,
+ stepForward,
+ stepBackward,
+ expandWindow,
+ } = useTimeWindow(start, end, applyTime, { zoomFactor });
+
+ const invalidShiftDescription = useEuiI18n(
+ 'euiTimeWindowButtons.invalidShiftLabel',
+ 'Cannot shift invalid time window'
+ );
+ const invalidZoomOutDescription = useEuiI18n(
+ 'euiTimeWindowButtons.invalidZoomOutLabel',
+ 'Cannot zoom out invalid time window'
+ );
+
+ const previousId = useGeneratedHtmlId({ prefix: 'previous' });
+ const previousLabel = useEuiI18n(
+ 'euiTimeWindowButtons.previousLabel',
+ 'Previous'
+ );
+ const previousTooltipContent = useEuiI18n(
+ 'euiTimeWindowButtons.previousDescription',
+ 'Previous {displayInterval}',
+ { displayInterval }
+ );
+
+ const zoomOutId = useGeneratedHtmlId({ prefix: 'zoom_out' });
+ const zoomOutLabel = useEuiI18n(
+ 'euiTimeWindowButtons.zoomOutLabel',
+ 'Zoom out'
+ );
+ const zoomOutTooltipContent = isInvalid
+ ? invalidZoomOutDescription
+ : zoomOutLabel;
+
+ const nextId = useGeneratedHtmlId({ prefix: 'next' });
+ const nextLabel = useEuiI18n('euiTimeWindowButtons.nextLabel', 'Next');
+ const nextTooltipContent = useEuiI18n(
+ 'euiTimeWindowButtons.nextDescription',
+ 'Next {displayInterval}',
+ { displayInterval }
+ );
+
+ if (!showZoomOut && !showShiftArrows) return null;
+
+ return (
+
+ {showShiftArrows && (
+
+ )}
+ {showZoomOut && (
+
+ )}
+ {showShiftArrows && (
+
+ )}
+
+ );
+};
+
+/**
+ * Partly adapted from date_picker/super_date_picker/quick_select_popover/quick_select.tsx
+ */
+export function useTimeWindow(
+ start: ShortDate,
+ end: ShortDate,
+ apply: ApplyTime,
+ options?: { zoomFactor?: TimeWindowButtonsConfig['zoomFactor'] }
+) {
+ const min = dateMath.parse(start);
+ const max = dateMath.parse(end, { roundUp: true });
+ const isInvalid = !min || !min.isValid() || !max || !max.isValid();
+ const windowDuration = isInvalid ? 1 : max.diff(min);
+ const zoomFactor = getPercentageMultiplier(
+ options?.zoomFactor ?? ZOOM_FACTOR_DEFAULT
+ );
+ const zoomAddition = windowDuration * (zoomFactor / 2); // Gets added to each end, that's why it's split in half
+ const prettyInterval = usePrettyInterval(false, windowDuration);
+ let displayInterval = isInvalid ? '' : prettyInterval;
+ if (
+ !isInvalid &&
+ !isRelativeToNow(start, end) &&
+ !isExactMinuteRange(windowDuration)
+ ) {
+ displayInterval = `~${displayInterval}`;
+ }
+
+ return {
+ displayInterval,
+ isInvalid,
+ stepForward,
+ stepBackward,
+ expandWindow,
+ };
+
+ function stepForward() {
+ if (isInvalid) return;
+ apply({
+ start: moment(max).toISOString(),
+ end: moment(max).add(windowDuration, 'ms').toISOString(),
+ });
+ }
+
+ function stepBackward() {
+ if (isInvalid) return;
+ apply({
+ start: moment(min).subtract(windowDuration, 'ms').toISOString(),
+ end: moment(min).toISOString(),
+ });
+ }
+
+ function expandWindow() {
+ if (isInvalid) return;
+ apply({
+ start: moment(min).subtract(zoomAddition, 'ms').toISOString(),
+ end: moment(max).add(zoomAddition, 'ms').toISOString(),
+ });
+ }
+}
+
+/**
+ * Get a number out of either 0.2 or "20%"
+ */
+function getPercentageMultiplier(value: number | string) {
+ const result =
+ typeof value === 'number'
+ ? value
+ : parseFloat(String(value).replace('%', '').trim());
+ if (isNaN(result))
+ throw new TypeError(
+ 'Please provide a valid number or percentage string e.g. "25%"'
+ );
+ return result > 1 ? result / 100 : result;
+}
+
+/**
+ * Useful to determine whether to show the tilde in the display
+ */
+function isExactMinuteRange(diffMs: number) {
+ // 60 * 1000 = ms per minute
+ return diffMs % (60 * 1000) === 0;
+}
diff --git a/packages/website/docs/components/forms/date-and-time/super-date-picker.mdx b/packages/website/docs/components/forms/date-and-time/super-date-picker.mdx
index 5f84da6fb90..5323c51e36b 100644
--- a/packages/website/docs/components/forms/date-and-time/super-date-picker.mdx
+++ b/packages/website/docs/components/forms/date-and-time/super-date-picker.mdx
@@ -569,6 +569,59 @@ export default () => {
};
```
+```mdx-code-block
+import { ZOOM_FACTOR_DEFAULT } from '@elastic/eui/es/components/date_picker/super_date_picker/time_window_buttons';
+```
+
+## Time window buttons
+
+A button group with buttons to modify the time window (or range) can be shown next to the main control. To show it, pass the `showTimeWindowButtons` prop to the `EuiSuperDatePicker` component. It allows to zoom out the window, or shift it both forwards or backwards.
+
+Zooming out will expand the window by {ZOOM_FACTOR_DEFAULT * 100}% by default. This is configurable via the `showTimeWindowButtons` prop, by passing a config object with a `zoomFactor` property e.g. `showTimeWindowButtons={{ zoomFactor: 0.42 }}`.
+
+```tsx interactive
+import React, { useState } from 'react';
+import {
+ EuiSuperDatePicker,
+ EuiFlexGroup,
+ EuiSwitch,
+ EuiSpacer,
+ OnTimeChangeProps,
+} from "@elastic/eui";
+
+export default () => {
+ const [showButtons, setShowButtons] = useState(true);
+ const [start, setStart] = useState("now-15m");
+ const [end, setEnd] = useState("now");
+
+ const onTimeChange = ({ start, end }: OnTimeChangeProps) => {
+ setStart(start);
+ setEnd(end);
+ };
+
+ return (
+ <>
+
+ setShowButtons(e.target.checked)}
+ label="Show time window buttons"
+ />
+
+
+
+ >
+ );
+};
+```
+
## Elastic pattern with KQL
The following is a demo pattern of how to layout the **EuiSuperDatePicker** alongside Elastic's KQL search bar using [EuiSearchBar](../search-and-filter/search-bar.mdx) and shrinking to fit when the search bar is in focus.