grommet
Version:
focus on the essential experience
389 lines (386 loc) • 19.4 kB
JavaScript
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/* eslint-disable max-len */
import React, { useCallback, useEffect, useRef } from 'react';
import styled from 'styled-components';
import { useLayoutEffect } from '../../utils/use-isomorphic-layout-effect';
import { focusStyle, normalizeColor } from '../../utils';
import { useThemeValue } from '../../utils/useThemeValue';
import { Box } from '../Box';
import { Drop } from '../Drop';
import { Text } from '../Text';
import { pad, getSectionName, SECTION_HOUR, SECTION_MINUTE, SECTION_PERIOD, SECTION_SECOND } from './utils';
var PopupColumnBox = styled(Box).withConfig({
displayName: "TimeInputPopup__PopupColumnBox",
componentId: "sc-r2j8uh-0"
})(["scrollbar-gutter:stable;scrollbar-width:thin;"]);
var PopupOption = styled.div.withConfig({
displayName: "TimeInputPopup__PopupOption",
componentId: "sc-r2j8uh-1"
})(["box-sizing:border-box;cursor:pointer;display:flex;padding:", ";border-radius:", ";background:", ";&:hover{background:", ";}&:focus-visible{", "}"], function (props) {
return props.theme.global.edgeSize.xxsmall + " " + props.theme.global.edgeSize.xsmall;
}, function (props) {
var _props$theme$global$c;
return (_props$theme$global$c = props.theme.global.control) == null || (_props$theme$global$c = _props$theme$global$c.border) == null ? void 0 : _props$theme$global$c.radius;
}, function (props) {
var _props$theme$timeInpu3;
if (props.$selected) {
var _props$theme$timeInpu;
return normalizeColor((_props$theme$timeInpu = props.theme.timeInput) == null || (_props$theme$timeInpu = _props$theme$timeInpu.drop) == null || (_props$theme$timeInpu = _props$theme$timeInpu.option) == null || (_props$theme$timeInpu = _props$theme$timeInpu.selected) == null ? void 0 : _props$theme$timeInpu.background, props.theme);
}
if (props.$active) {
var _props$theme$timeInpu2;
return normalizeColor((_props$theme$timeInpu2 = props.theme.timeInput) == null || (_props$theme$timeInpu2 = _props$theme$timeInpu2.drop) == null || (_props$theme$timeInpu2 = _props$theme$timeInpu2.option) == null || (_props$theme$timeInpu2 = _props$theme$timeInpu2.hover) == null ? void 0 : _props$theme$timeInpu2.background, props.theme);
}
return normalizeColor((_props$theme$timeInpu3 = props.theme.timeInput) == null || (_props$theme$timeInpu3 = _props$theme$timeInpu3.drop) == null || (_props$theme$timeInpu3 = _props$theme$timeInpu3.option) == null ? void 0 : _props$theme$timeInpu3.background, props.theme);
}, function (props) {
var _props$theme$timeInpu6;
if (props.$selected) {
var _props$theme$timeInpu4, _props$theme$timeInpu5;
return normalizeColor(((_props$theme$timeInpu4 = props.theme.timeInput) == null || (_props$theme$timeInpu4 = _props$theme$timeInpu4.drop) == null || (_props$theme$timeInpu4 = _props$theme$timeInpu4.option) == null || (_props$theme$timeInpu4 = _props$theme$timeInpu4.selected) == null || (_props$theme$timeInpu4 = _props$theme$timeInpu4.hover) == null ? void 0 : _props$theme$timeInpu4.background) || ((_props$theme$timeInpu5 = props.theme.timeInput) == null || (_props$theme$timeInpu5 = _props$theme$timeInpu5.drop) == null || (_props$theme$timeInpu5 = _props$theme$timeInpu5.option) == null || (_props$theme$timeInpu5 = _props$theme$timeInpu5.selected) == null ? void 0 : _props$theme$timeInpu5.background), props.theme);
}
return normalizeColor((_props$theme$timeInpu6 = props.theme.timeInput) == null || (_props$theme$timeInpu6 = _props$theme$timeInpu6.drop) == null || (_props$theme$timeInpu6 = _props$theme$timeInpu6.option) == null || (_props$theme$timeInpu6 = _props$theme$timeInpu6.hover) == null ? void 0 : _props$theme$timeInpu6.background, props.theme);
}, focusStyle({
inset: true
}));
var optionKey = function optionKey(label, option) {
return label.toLowerCase() + "-" + option;
};
var PopupColumn = function PopupColumn(_ref) {
var _theme$timeInput;
var activeSection = _ref.activeSection,
format = _ref.format,
formatMessage = _ref.formatMessage,
label = _ref.label,
messages = _ref.messages,
onClickCommitOption = _ref.onClickCommitOption,
onPointerCommitOption = _ref.onPointerCommitOption,
onSetSection = _ref.onSetSection,
options = _ref.options,
section = _ref.section,
sections = _ref.sections,
theme = _ref.theme;
return /*#__PURE__*/React.createElement(PopupColumnBox, {
role: "listbox",
"aria-label": label,
gap: "xxsmall",
height: {
max: ((_theme$timeInput = theme.timeInput) == null || (_theme$timeInput = _theme$timeInput.drop) == null || (_theme$timeInput = _theme$timeInput.column) == null ? void 0 : _theme$timeInput.maxHeight) || theme.global.size.small
},
overflow: "auto",
flex: {
grow: 0,
shrink: 0
}
}, options.map(function (option) {
var _theme$timeInput2;
var key = optionKey(label, option);
var selected = section === SECTION_HOUR && sections.hour === option || section === SECTION_MINUTE && sections.minute === option || section === SECTION_SECOND && sections.second === option || section === SECTION_PERIOD && sections.period === option;
var optionColor = selected ? ((_theme$timeInput2 = theme.timeInput) == null || (_theme$timeInput2 = _theme$timeInput2.drop) == null || (_theme$timeInput2 = _theme$timeInput2.option) == null || (_theme$timeInput2 = _theme$timeInput2.selected) == null ? void 0 : _theme$timeInput2.color) || 'text' : 'text';
var isActive = selected && activeSection === section;
return /*#__PURE__*/React.createElement(PopupOption, {
key: key,
"data-option-key": key,
role: "option",
"aria-selected": selected,
tabIndex: isActive ? 0 : -1,
"aria-label": (section === SECTION_PERIOD ? option : pad(option)) + " " + getSectionName(section, format, formatMessage, messages),
$active: isActive,
$selected: selected,
onMouseDown: function onMouseDown(event) {
if (event.button !== 0) return;
// Commit on pointer press so momentum scroll does not swallow
// the first click commit on some trackpad/mouse flows.
event.preventDefault();
onPointerCommitOption(section, option);
},
onClick: function onClick() {
return onClickCommitOption(section, option);
},
onFocus: function onFocus() {
return onSetSection(section);
}
}, /*#__PURE__*/React.createElement(Text, {
size: theme.global.input.font.size || 'small',
weight: selected ? 'bold' : 'normal',
color: optionColor
}, section === SECTION_PERIOD ? option : pad(option)));
}));
};
var TimeInputPopup = function TimeInputPopup(_ref2) {
var _theme$timeInput3, _theme$timeInput4;
var activeSection = _ref2.activeSection,
align = _ref2.align,
format = _ref2.format,
formatMessage = _ref2.formatMessage,
hoursOptions = _ref2.hoursOptions,
id = _ref2.id,
incrementSection = _ref2.incrementSection,
messages = _ref2.messages,
minuteOptions = _ref2.minuteOptions,
moveSection = _ref2.moveSection,
onAccept = _ref2.onAccept,
onClose = _ref2.onClose,
onFocusLeave = _ref2.onFocusLeave,
secondOptions = _ref2.secondOptions,
sectionOrder = _ref2.sectionOrder,
sections = _ref2.sections,
setActiveSection = _ref2.setActiveSection,
setSectionValue = _ref2.setSectionValue,
target = _ref2.target,
dropProps = _ref2.dropProps,
label = _ref2.label;
var _useThemeValue = useThemeValue(),
theme = _useThemeValue.theme;
var dialogRef = useRef();
var pointerDownInsideRef = useRef(false);
var pointerSelectionCommittedRef = useRef(false);
var suppressNextAutoScrollRef = useRef(false);
var wheelInteractionTimeoutRef = useRef();
var pointerReleaseTimeoutRef = useRef();
var clearPointerReleaseTimeout = useCallback(function () {
if (pointerReleaseTimeoutRef.current) {
window.clearTimeout(pointerReleaseTimeoutRef.current);
pointerReleaseTimeoutRef.current = undefined;
}
}, []);
var clearInteractionInProgress = useCallback(function () {
clearPointerReleaseTimeout();
pointerDownInsideRef.current = false;
if (wheelInteractionTimeoutRef.current) {
window.clearTimeout(wheelInteractionTimeoutRef.current);
wheelInteractionTimeoutRef.current = undefined;
}
}, [clearPointerReleaseTimeout]);
var releaseInteractionAfterClick = useCallback(function () {
clearPointerReleaseTimeout();
// Keep lock through click handler + resulting render/effect cycle.
pointerReleaseTimeoutRef.current = window.setTimeout(function () {
pointerDownInsideRef.current = false;
pointerReleaseTimeoutRef.current = undefined;
}, 0);
}, [clearPointerReleaseTimeout]);
var markInteractionInProgress = useCallback(function () {
pointerDownInsideRef.current = true;
}, []);
var suppressNextAutoScroll = useCallback(function () {
suppressNextAutoScrollRef.current = true;
}, []);
var commitOptionSelection = useCallback(function (section, option) {
setActiveSection(section);
setSectionValue(section, option);
}, [setActiveSection, setSectionValue]);
var commitPointerOptionSelection = useCallback(function (section, option) {
pointerSelectionCommittedRef.current = true;
suppressNextAutoScroll();
commitOptionSelection(section, option);
}, [commitOptionSelection, suppressNextAutoScroll]);
var commitClickOptionSelection = useCallback(function (section, option) {
if (pointerSelectionCommittedRef.current) {
pointerSelectionCommittedRef.current = false;
return;
}
commitOptionSelection(section, option);
}, [commitOptionSelection]);
var onPopupWheelCapture = useCallback(function () {
pointerDownInsideRef.current = true;
if (wheelInteractionTimeoutRef.current) {
window.clearTimeout(wheelInteractionTimeoutRef.current);
}
// Trackpad and wheel events can continue after the pointer sequence.
// Keep interaction lock briefly so refocus does not steal first selection.
wheelInteractionTimeoutRef.current = window.setTimeout(function () {
pointerDownInsideRef.current = false;
wheelInteractionTimeoutRef.current = undefined;
}, 120);
}, []);
useEffect(function () {
return function () {
clearPointerReleaseTimeout();
if (wheelInteractionTimeoutRef.current) {
window.clearTimeout(wheelInteractionTimeoutRef.current);
}
};
}, [clearPointerReleaseTimeout]);
var popupSections = [{
section: SECTION_HOUR,
label: 'hour',
options: hoursOptions
}, {
section: SECTION_MINUTE,
label: 'minute',
options: minuteOptions
}, {
section: SECTION_SECOND,
label: 'second',
options: secondOptions
}, {
section: SECTION_PERIOD,
label: 'period',
options: ['AM', 'PM']
}];
var visiblePopupSections = popupSections.filter(function (_ref3) {
var section = _ref3.section;
return sectionOrder.includes(section);
});
var scrollSelectedOptionsIntoView = useCallback(function () {
var _sectionLabel, _sectionValue;
if (pointerDownInsideRef.current) return;
if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false;
return;
}
var popupNode = dialogRef.current;
if (!popupNode) return;
var sectionLabel = (_sectionLabel = {}, _sectionLabel[SECTION_HOUR] = 'hour', _sectionLabel[SECTION_MINUTE] = 'minute', _sectionLabel[SECTION_SECOND] = 'second', _sectionLabel[SECTION_PERIOD] = 'period', _sectionLabel);
var sectionValue = (_sectionValue = {}, _sectionValue[SECTION_HOUR] = sections.hour !== undefined ? sections.hour : hoursOptions[0], _sectionValue[SECTION_MINUTE] = sections.minute !== undefined ? sections.minute : minuteOptions[0], _sectionValue[SECTION_SECOND] = sections.second !== undefined ? sections.second : secondOptions[0], _sectionValue[SECTION_PERIOD] = sections.period || 'AM', _sectionValue);
visiblePopupSections.forEach(function (_ref4) {
var section = _ref4.section;
var labelValue = sectionLabel[section];
if (!labelValue) return;
var listboxNode = popupNode.querySelector("[role=\"listbox\"][aria-label=\"" + labelValue + "\"]");
if (!listboxNode) return;
var selectedNode = popupNode.querySelector("[data-option-key=\"" + optionKey(labelValue, sectionValue[section]) + "\"]") || listboxNode.querySelector('[role="option"][aria-selected="true"]');
if (selectedNode) {
if (selectedNode.scrollIntoView) {
selectedNode.scrollIntoView({
block: 'nearest'
});
}
// Center selected value in each listbox so all sections (hh/mm/ss)
// are consistently aligned on open, not just the focused section.
var selectedOffsetTop = selectedNode.offsetTop;
var selectedHeight = selectedNode.offsetHeight;
var targetScrollTop = selectedOffsetTop - (listboxNode.clientHeight / 2 - selectedHeight / 2);
listboxNode.scrollTop = Math.max(0, targetScrollTop);
}
});
}, [hoursOptions, minuteOptions, secondOptions, sections, visiblePopupSections]);
var focusCurrentPopupOption = useCallback(function () {
var _labelMap, _keyMap, _dialogRef$current, _dialogRef$current2;
var labelMap = (_labelMap = {}, _labelMap[SECTION_HOUR] = 'hour', _labelMap[SECTION_MINUTE] = 'minute', _labelMap[SECTION_SECOND] = 'second', _labelMap[SECTION_PERIOD] = 'period', _labelMap);
var keyMap = (_keyMap = {}, _keyMap[SECTION_HOUR] = optionKey('hour', sections.hour !== undefined ? sections.hour : hoursOptions[0]), _keyMap[SECTION_MINUTE] = optionKey('minute', sections.minute !== undefined ? sections.minute : minuteOptions[0]), _keyMap[SECTION_SECOND] = optionKey('second', sections.second !== undefined ? sections.second : secondOptions[0]), _keyMap[SECTION_PERIOD] = optionKey('period', sections.period || 'AM'), _keyMap);
var selector = "[data-option-key=\"" + keyMap[activeSection] + "\"]";
var node = (_dialogRef$current = dialogRef.current) == null ? void 0 : _dialogRef$current.querySelector(selector);
if (node) {
node.focus();
return true;
}
// Fallback: if current section value has no matching option
// (e.g., minute=31 with minuteStep=15), focus first option in section.
var sectionLabel = labelMap[activeSection];
if (!sectionLabel) return false;
var fallbackNode = (_dialogRef$current2 = dialogRef.current) == null ? void 0 : _dialogRef$current2.querySelector("[role=\"listbox\"][aria-label=\"" + sectionLabel + "\"] [role=\"option\"]");
if (fallbackNode) {
fallbackNode.focus();
return true;
}
return false;
}, [activeSection, hoursOptions, minuteOptions, secondOptions, sections]);
useLayoutEffect(function () {
// Avoid stealing pointer interactions: while the user is actively
// clicking inside the popup, let that click settle before refocusing.
if (pointerDownInsideRef.current) return undefined;
var scrollRaf = requestAnimationFrame(function () {
scrollSelectedOptionsIntoView();
});
var rafB;
var rafA = requestAnimationFrame(function () {
scrollSelectedOptionsIntoView();
var focused = focusCurrentPopupOption();
// Retry one more frame to handle occasional mount timing races.
if (!focused) {
rafB = requestAnimationFrame(function () {
scrollSelectedOptionsIntoView();
focusCurrentPopupOption();
});
}
});
return function () {
window.cancelAnimationFrame(scrollRaf);
window.cancelAnimationFrame(rafA);
if (rafB) window.cancelAnimationFrame(rafB);
};
}, [focusCurrentPopupOption, scrollSelectedOptionsIntoView]);
return /*#__PURE__*/React.createElement(Drop, _extends({
id: id ? id + "__drop" : undefined,
target: target,
align: align,
onEsc: onClose,
onClickOutside: onClose
}, dropProps), /*#__PURE__*/React.createElement(Box, {
ref: dialogRef,
role: "dialog",
"aria-label": label,
direction: "row",
width: {
width: (_theme$timeInput3 = theme.timeInput) == null || (_theme$timeInput3 = _theme$timeInput3.drop) == null ? void 0 : _theme$timeInput3.width,
max: '100%'
},
minHeight: (_theme$timeInput4 = theme.timeInput) == null || (_theme$timeInput4 = _theme$timeInput4.drop) == null ? void 0 : _theme$timeInput4.minHeight,
gap: "xsmall",
pad: "small",
onPointerDownCapture: markInteractionInProgress,
onPointerUpCapture: releaseInteractionAfterClick,
onPointerCancelCapture: clearInteractionInProgress,
onWheelCapture: onPopupWheelCapture,
onKeyDown: function onKeyDown(event) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
} else if (event.key === ' ' || event.key === 'Spacebar') {
var _event$target, _document$activeEleme;
event.preventDefault();
var focusedOption = ((_event$target = event.target) == null || _event$target.closest == null ? void 0 : _event$target.closest('[role="option"]')) || ((_document$activeEleme = document.activeElement) == null || _document$activeEleme.closest == null ? void 0 : _document$activeEleme.closest('[role="option"]'));
focusedOption == null || focusedOption.click == null || focusedOption.click();
} else if (event.key === 'Tab') {
event.preventDefault();
setActiveSection(moveSection(event.shiftKey ? -1 : 1));
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
setActiveSection(moveSection(-1));
} else if (event.key === 'ArrowRight') {
event.preventDefault();
setActiveSection(moveSection(1));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
incrementSection(activeSection, -1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
incrementSection(activeSection, 1);
} else if (event.key === 'Enter') {
event.preventDefault();
onAccept == null || onAccept();
onClose();
}
},
onBlurCapture: function onBlurCapture(event) {
var nextFocusTarget = event.relatedTarget;
// Clicking the scrollbar blurs the focused option with
// relatedTarget = null. Keep the popup open for that interaction.
if (!nextFocusTarget) return;
if (!event.currentTarget.contains(nextFocusTarget)) {
onFocusLeave == null || onFocusLeave();
}
}
}, visiblePopupSections.map(function (_ref5) {
var section = _ref5.section,
sectionLabel = _ref5.label,
options = _ref5.options;
return /*#__PURE__*/React.createElement(PopupColumn, {
key: sectionLabel,
activeSection: activeSection,
format: format,
formatMessage: formatMessage,
label: sectionLabel,
messages: messages,
onClickCommitOption: commitClickOptionSelection,
onPointerCommitOption: commitPointerOptionSelection,
onSetSection: setActiveSection,
options: options,
section: section,
sections: sections,
theme: theme
});
})));
};
export { TimeInputPopup };