@atlaskit/datetime-picker
Version:
A date time picker allows the user to select an associated date and time.
302 lines (297 loc) • 9.92 kB
JavaScript
import _extends from "@babel/runtime/helpers/extends";
import React, { forwardRef, useCallback, useEffect, useReducer, useState } from 'react';
// oxlint-disable-next-line @atlassian/no-restricted-imports
import { format, isValid } from 'date-fns';
import { usePlatformLeafEventHandler } from '@atlaskit/analytics-next/usePlatformLeafEventHandler';
import __noop from '@atlaskit/ds-lib/noop';
import { createLocalizationProvider } from '@atlaskit/locale/localization-provider';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import Select from '@atlaskit/select/default';
import CreatableSelect from '@atlaskit/select/creatable-select';
import { mergeStyles } from '@atlaskit/react-select/styles';
import { defaultTimes } from '../internal/default-times';
import { EmptyComponent } from '../internal/empty-component';
import { FixedLayerMenu } from '../internal/fixed-layer-menu';
import { FixedLayerMenuTopLayer } from '../internal/fixed-layer-menu-top-layer';
import parseTime from '../internal/parse-time';
import { convertTokens } from '../internal/parse-tokens';
import { placeholderDatetime } from '../internal/placeholder-date-time';
import { makeSingleValue } from '../internal/single-value';
const packageName = "@atlaskit/datetime-picker";
const packageVersion = "18.7.2";
const defaultTimeFormat = 'h:mma';
const menuStyles = {
/* Need to remove default absolute positioning as that causes issues with position fixed */
position: 'static',
/* Need to add overflow to the element with max-height, otherwise causes overflow issues in IE11 */
overflowY: 'auto',
/* React-Popper has already offset the menu so we need to reset the margin, otherwise the offset value is doubled */
margin: 0
};
const analyticsAttributes = {
componentName: 'timePicker',
packageName,
packageVersion
};
/**
* __Time picker__
*
* A time picker allows the user to select a specific time.
*
* - [Examples](https://atlassian.design/components/datetime-picker/time-picker/examples)
* - [Code](https://atlassian.design/components/datetime-picker/time-picker/code)
* - [Usage](https://atlassian.design/components/datetime-picker/time-picker/usage)
*/
const TimePicker = /*#__PURE__*/forwardRef(({
'aria-describedby': ariaDescribedBy,
appearance = 'default',
autoFocus = false,
clearControlLabel = 'clear timepicker',
defaultIsOpen = false,
defaultValue = '',
formatDisplayLabel,
hideIcon = false,
id = '',
innerProps = {},
isDisabled = false,
isInvalid = false,
isRequired = false,
isOpen: providedIsOpen,
label = '',
locale = 'en-US',
name = '',
onBlur: providedOnBlur = __noop,
onChange: providedOnChange = __noop,
onFocus: providedOnFocus = __noop,
parseInputValue = (time, _timeFormat) => parseTime(time),
placeholder,
selectProps = {},
spacing = 'default',
testId,
timeFormat,
timeIsEditable = false,
times = defaultTimes,
value: providedValue
}, _ref) => {
const [containerRef, setContainerRef] = useState(null);
/**
* When being cleared from the icon the TimePicker is blurred.
* This variable defines whether the default onMenuOpen or onMenuClose
* events should behave as normal
*/
const [clearingFromIcon, setClearingFromIcon] = useState(false);
// TODO: Remove isFocused? Does it do anything?
const [_, setIsFocused] = useState(false);
const [isOpen, setIsOpen] = useState(defaultIsOpen);
const [value, setValue] = useState(defaultValue);
// Hack to force update: https://legacy.reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate
const [, forceUpdate] = useReducer(x => x + 1, 0);
const providedOnChangeWithAnalytics = usePlatformLeafEventHandler({
fn: providedOnChange,
action: 'selectedTime',
...analyticsAttributes
});
useEffect(() => {
if (providedValue) {
setValue(providedValue);
}
}, [providedValue]);
useEffect(() => {
if (providedIsOpen) {
setIsOpen(providedIsOpen);
}
}, [providedIsOpen]);
const onChange = useCallback((newValue, action) => {
const rawValue = newValue ? newValue.value || newValue : '';
const finalValue = rawValue.toString();
setValue(finalValue);
if (action && action.action === 'clear') {
setClearingFromIcon(true);
}
providedOnChangeWithAnalytics(finalValue);
}, [providedOnChangeWithAnalytics]);
/**
* Only allow custom times if timeIsEditable prop is true
*/
const onCreateOption = inputValue => {
if (timeIsEditable) {
let sanitizedInput;
try {
sanitizedInput = parseInputValue(inputValue, timeFormat || defaultTimeFormat);
} catch {
return; // do nothing, the main validation should happen in the form
}
const includesSeconds = !!(timeFormat && /[:.]?(s|ss)/.test(timeFormat));
const formatFormat = includesSeconds ? 'HH:mm:ss' : 'HH:mm';
const formattedValue = format(sanitizedInput, formatFormat) || '';
setValue(formattedValue);
providedOnChangeWithAnalytics(formattedValue);
} else {
providedOnChangeWithAnalytics(inputValue);
}
};
const onMenuOpen = () => {
// Don't open menu after the user has clicked clear
if (clearingFromIcon) {
setClearingFromIcon(false);
} else {
setIsOpen(true);
}
};
const onMenuClose = () => {
// Don't close menu after the user has clicked clear
if (clearingFromIcon) {
setClearingFromIcon(false);
} else {
setIsOpen(false);
}
};
const setInternalContainerRef = ref => {
const oldRef = containerRef;
setContainerRef(ref);
// Cause a re-render if we're getting the container ref for the first time
// as the layered menu requires it for dimension calculation
if (oldRef === null && ref !== null) {
forceUpdate();
}
};
const onBlur = event => {
setIsFocused(false);
providedOnBlur(event);
};
const onFocus = event => {
setIsFocused(true);
providedOnFocus(event);
};
const onSelectKeyDown = event => {
const {
key
} = event;
const keyPressed = key.toLowerCase();
if (clearingFromIcon && (keyPressed === 'backspace' || keyPressed === 'delete')) {
// If being cleared from keyboard, don't change behaviour
setClearingFromIcon(false);
}
};
const ICON_PADDING = 2;
const GRID_SIZE = 8;
const l10n = createLocalizationProvider(locale);
const {
styles: selectStyles = {},
...otherSelectProps
} = selectProps;
const SelectComponent = timeIsEditable ? CreatableSelect : Select;
/**
* There are multiple props that can change how the time is formatted.
* The priority of props used is:
* 1. formatDisplayLabel
* 2. timeFormat
* 3. locale
*/
const formatTime = time => {
if (formatDisplayLabel) {
return formatDisplayLabel(time, timeFormat || defaultTimeFormat);
}
const date = parseTime(time);
if (!(date instanceof Date)) {
return '';
}
if (!isValid(date)) {
return time;
}
if (timeFormat) {
return format(date, convertTokens(timeFormat));
}
return l10n.formatTime(date);
};
const options = times.map(time => {
return {
label: formatTime(time),
value: time
};
});
let initialValue;
if (providedValue !== null && providedValue !== undefined && providedValue !== '') {
initialValue = {
label: formatTime(providedValue),
value: providedValue
};
} else if (providedValue !== '' && value) {
initialValue = {
label: formatTime(value),
value: value
};
} else {
initialValue = null;
}
const SingleValue = makeSingleValue({
lang: locale
});
const selectComponents = {
DropdownIndicator: EmptyComponent,
Menu: fg('platform-dst-top-layer') ? FixedLayerMenuTopLayer : FixedLayerMenu,
SingleValue,
...(hideIcon && {
ClearIndicator: EmptyComponent
})
};
const renderIconContainer = Boolean(!hideIcon && value);
const mergedStyles = mergeStyles(selectStyles, {
control: base => ({
...base
}),
menu: base => ({
...base,
...menuStyles,
// Fixed positioned elements no longer inherit width from their parent, so we must explicitly set the
// menu width to the width of our container
width: containerRef ? containerRef.getBoundingClientRect().width : 'auto'
}),
indicatorsContainer: base => ({
...base,
paddingLeft: renderIconContainer ? ICON_PADDING : 0,
paddingRight: renderIconContainer ? GRID_SIZE - ICON_PADDING : 0
})
});
return /*#__PURE__*/React.createElement("div", _extends({}, innerProps, {
ref: setInternalContainerRef,
"data-testid": testId && `${testId}--container`
}), /*#__PURE__*/React.createElement("input", {
name: name,
type: "hidden",
value: value,
"data-testid": testId && `${testId}--input`,
onKeyDown: onSelectKeyDown
}), /*#__PURE__*/React.createElement(SelectComponent, _extends({
"aria-describedby": ariaDescribedBy,
"aria-label": label || undefined,
appearance: appearance,
autoFocus: autoFocus,
clearControlLabel: clearControlLabel,
components: selectComponents,
inputId: id,
isClearable: true,
isDisabled: isDisabled,
isRequired: isRequired,
menuIsOpen: isOpen && !isDisabled,
menuPlacement: "auto",
openMenuOnFocus: true,
onBlur: onBlur,
onCreateOption: onCreateOption,
onChange: onChange,
options: options,
onFocus: onFocus,
onMenuOpen: onMenuOpen,
onMenuClose: onMenuClose,
placeholder: placeholder || l10n.formatTime(placeholderDatetime),
styles: mergedStyles,
value: initialValue,
spacing: spacing
// We need this to get things to work, even though it's not supported.
,
fixedLayerRef: containerRef,
isInvalid: isInvalid,
testId: testId
}, otherSelectProps)));
});
export default TimePicker;