@via-profit/ui-kit 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,9 @@ class ColorParser {
11
11
  throw new Error(`Invalid color value: ${value}`);
12
12
  }
13
13
  const inputColor = value.trim().toLowerCase();
14
+ if (inputColor === 'transparent') {
15
+ return { r: 0, g: 0, b: 0, a: 0 };
16
+ }
14
17
  // Проверка на именованный цвет
15
18
  if (this.webColors[inputColor]) {
16
19
  return this.parse(this.webColors[inputColor]);
@@ -70,27 +73,64 @@ class ColorParser {
70
73
  }
71
74
  throw new Error(`Invalid HEX color: ${hex}`);
72
75
  }
76
+ /**
77
+ * Returns function arguments: `rgb(10, 20 30 / 50%)` -> ['10', '20', '30', '50%']
78
+ */
79
+ static getArgs(value) {
80
+ const inner = value.substring(value.indexOf('(') + 1, value.lastIndexOf(')'));
81
+ return inner.split(/[\s,/]+/).filter(Boolean);
82
+ }
83
+ static parseNumber(value, color) {
84
+ const num = parseFloat(value);
85
+ if (isNaN(num)) {
86
+ throw new Error(`Invalid color value: ${color}`);
87
+ }
88
+ return num;
89
+ }
90
+ /**
91
+ * Alpha may be a number (0..1) or a percentage (0%..100%)
92
+ */
93
+ static parseAlpha(value, color) {
94
+ if (typeof value === 'undefined') {
95
+ return 1;
96
+ }
97
+ const num = this.parseNumber(value, color);
98
+ return this.clamp(value.endsWith('%') ? num / 100 : num, 0, 1);
99
+ }
73
100
  static parseRgb(rgb) {
74
- const matches = rgb.match(/[\d.]+/g);
75
- if (!matches || matches.length < 3) {
101
+ const args = this.getArgs(rgb);
102
+ if (args.length < 3) {
76
103
  throw new Error(`Invalid RGB color: ${rgb}`);
77
104
  }
105
+ // Channel may be a number (0..255) or a percentage (0%..100%)
106
+ const channel = (value) => {
107
+ const num = this.parseNumber(value, rgb);
108
+ return this.clamp(Math.round(value.endsWith('%') ? (num * 255) / 100 : num), 0, 255);
109
+ };
78
110
  return {
79
- r: this.clamp(parseInt(matches[0], 10), 0, 255),
80
- g: this.clamp(parseInt(matches[1], 10), 0, 255),
81
- b: this.clamp(parseInt(matches[2], 10), 0, 255),
82
- a: matches[3] ? this.clamp(parseFloat(matches[3]), 0, 1) : 1,
111
+ r: channel(args[0]),
112
+ g: channel(args[1]),
113
+ b: channel(args[2]),
114
+ a: this.parseAlpha(args[3], rgb),
83
115
  };
84
116
  }
85
117
  static parseHsl(hsl) {
86
- const matches = hsl.match(/[\d.]+/g);
87
- if (!matches || matches.length < 3) {
118
+ const args = this.getArgs(hsl);
119
+ if (args.length < 3) {
88
120
  throw new Error(`Invalid HSL color: ${hsl}`);
89
121
  }
90
- const h = parseFloat(matches[0]) / 360;
91
- const s = parseFloat(matches[1]) / 100;
92
- const l = parseFloat(matches[2]) / 100;
93
- const a = matches[3] ? this.clamp(parseFloat(matches[3]), 0, 1) : 1;
122
+ let hue = this.parseNumber(args[0], hsl);
123
+ if (args[0].endsWith('turn')) {
124
+ hue *= 360;
125
+ }
126
+ else if (args[0].endsWith('rad')) {
127
+ hue = (hue * 180) / Math.PI;
128
+ }
129
+ // Negative and >360 hues are wrapped around the color wheel
130
+ const h = (((hue % 360) + 360) % 360) / 360;
131
+ const s = this.clamp(this.parseNumber(args[1], hsl), 0, 100) / 100;
132
+ const l = this.clamp(this.parseNumber(args[2], hsl), 0, 100) / 100;
133
+ const a = this.parseAlpha(args[3], hsl);
94
134
  const rgb = this.hslToRgb(h, s, l);
95
135
  return {
96
136
  r: rgb[0],
@@ -63,6 +63,10 @@ export type DatePickerProps = Omit<TextFieldProps, 'value' | 'onChange' | 'overr
63
63
  /**
64
64
  * Label for the Today button. If label passed, then button will be rendered
65
65
  */
66
+ readonly todayButtonLabel?: string;
67
+ /**
68
+ * @deprecated Use `todayButtonLabel` instead
69
+ */
66
70
  readonly toodayButtonLabel?: string;
67
71
  /**
68
72
  * Heading
@@ -49,23 +49,38 @@ const StyledMaskedField = (0, styled_1.default)(MaskedField_1.default) `
49
49
  `}
50
50
  `;
51
51
  const DatePicker = props => {
52
- const { value, template, onChange, calendarButtonTooltip, readOnly, disabled, minDate = new Date(new Date().getFullYear() - 100, 0, 1, 0, 0, 0), maxDate = new Date(new Date().getFullYear() + 100, 0, 1, 0, 0, 0), weekStartDay = 'monday', locale = 'ru-RU', badges = [], displayLeadingZero = false, resetButtonLabel, toodayButtonLabel, heading, subheading, view, views, footer, overrides } = props, restInputProps = __rest(props, ["value", "template", "onChange", "calendarButtonTooltip", "readOnly", "disabled", "minDate", "maxDate", "weekStartDay", "locale", "badges", "displayLeadingZero", "resetButtonLabel", "toodayButtonLabel", "heading", "subheading", "view", "views", "footer", "overrides"]);
53
- const {
54
- // Calendar overrides
55
- Body, Cell, EmptyCell, Paper, Header, WeekRow, DateContainer, Toolbar, YearsSelector, MonthsSelector, MonthCell, DayBadge, Footer, ControlButton, Heading, Subheading, IconPrev, IconNext, WeekDaysBar,
56
- // Textfield overrides
57
- Input, ErrorText, IconWrapper, InputWrapper, Label, Asterisk, Container, } = overrides || {};
52
+ const { value, template, onChange, calendarButtonTooltip, readOnly, disabled, minDate = new Date(new Date().getFullYear() - 100, 0, 1, 0, 0, 0), maxDate = new Date(new Date().getFullYear() + 100, 0, 1, 0, 0, 0), weekStartDay = 'monday', locale = 'ru-RU', badges = [], displayLeadingZero = false, resetButtonLabel, todayButtonLabel: todayButtonLabelProp, toodayButtonLabel, markToday, weekDayLabelFormat, heading, subheading, view, views, footer, overrides } = props, restInputProps = __rest(props, ["value", "template", "onChange", "calendarButtonTooltip", "readOnly", "disabled", "minDate", "maxDate", "weekStartDay", "locale", "badges", "displayLeadingZero", "resetButtonLabel", "todayButtonLabel", "toodayButtonLabel", "markToday", "weekDayLabelFormat", "heading", "subheading", "view", "views", "footer", "overrides"]);
53
+ const todayButtonLabel = todayButtonLabelProp !== null && todayButtonLabelProp !== void 0 ? todayButtonLabelProp : toodayButtonLabel;
54
+ const calendarOverrides = react_1.default.useMemo(() => {
55
+ if (!overrides) {
56
+ return undefined;
57
+ }
58
+ const { Input: _Input, ErrorText: _ErrorText, IconWrapper: _IconWrapper, InputWrapper: _InputWrapper, Label: _Label, Asterisk: _Asterisk, Container: _Container } = overrides, rest = __rest(overrides, ["Input", "ErrorText", "IconWrapper", "InputWrapper", "Label", "Asterisk", "Container"]);
59
+ return rest;
60
+ }, [overrides]);
61
+ const textFieldOverrides = react_1.default.useMemo(() => {
62
+ if (!overrides) {
63
+ return undefined;
64
+ }
65
+ const { Input, ErrorText, IconWrapper, InputWrapper, Label, Asterisk, Container } = overrides;
66
+ return { Input, ErrorText, IconWrapper, InputWrapper, Label, Asterisk, Container };
67
+ }, [overrides]);
58
68
  const [currentValue, setCurrentValue] = react_1.default.useState(value);
59
69
  const valueRef = react_1.default.useRef(value);
60
70
  const [textFieldRef, setTextFieldRef] = react_1.default.useState(null);
61
71
  const [isOpen, setOpenSate] = react_1.default.useState(false);
62
72
  const { getMaskByTemplate, formatInputByTemplate, parseInputByTemplate } = (0, useDatePickerFormat_1.default)();
63
73
  react_1.default.useEffect(() => {
64
- const a = value || new Date();
65
- const b = valueRef.current || new Date();
66
- if (a.getFullYear() !== b.getFullYear() ||
67
- a.getMonth() !== b.getMonth() ||
68
- a.getDate() !== b.getDate()) {
74
+ const a = value;
75
+ const b = valueRef.current;
76
+ // null <-> date transitions must be detected too
77
+ const isSameDay = a === b ||
78
+ (a !== null &&
79
+ b !== null &&
80
+ a.getFullYear() === b.getFullYear() &&
81
+ a.getMonth() === b.getMonth() &&
82
+ a.getDate() === b.getDate());
83
+ if (!isSameDay) {
69
84
  valueRef.current = value;
70
85
  setCurrentValue(value);
71
86
  }
@@ -118,7 +133,6 @@ const DatePicker = props => {
118
133
  parseInputByTemplate,
119
134
  template,
120
135
  ]);
121
- const calendarValue = react_1.default.useMemo(() => currentValue || new Date(), [currentValue]);
122
136
  const calendarChange = react_1.default.useCallback(date => {
123
137
  if (date) {
124
138
  onChange(date);
@@ -126,9 +140,9 @@ const DatePicker = props => {
126
140
  setOpenSate(false);
127
141
  }, [onChange]);
128
142
  return (react_1.default.createElement(react_1.default.Fragment, null,
129
- react_1.default.createElement(StyledMaskedField, Object.assign({ onClick: handleClick, disabled: disabled, mask: getMaskByTemplate(template), ref: setTextFieldRef, readOnly: readOnly, endIcon: endIcon }, restInputProps, { value: currentValue ? formatInputByTemplate(currentValue, template) : '', onChange: handleChange })),
143
+ react_1.default.createElement(StyledMaskedField, Object.assign({ onClick: handleClick, disabled: disabled, mask: getMaskByTemplate(template), ref: setTextFieldRef, readOnly: readOnly, endIcon: endIcon }, restInputProps, { value: currentValue ? formatInputByTemplate(currentValue, template) : '', onChange: handleChange, overrides: textFieldOverrides })),
130
144
  react_1.default.createElement(ClickOutside_1.default, { onOutsideClick: () => setOpenSate(false), mouseEvent: "onMouseDown" },
131
145
  react_1.default.createElement(Popper_1.default, { isOpen: isOpen, anchorElement: textFieldRef },
132
- react_1.default.createElement(Calendar_1.default, { minDate: minDate, maxDate: maxDate, weekStartDay: weekStartDay, locale: locale, badges: badges, displayLeadingZero: displayLeadingZero, resetButtonLabel: resetButtonLabel, todayButtonLabel: toodayButtonLabel, heading: heading, subheading: subheading, view: view, views: views, footer: footer, value: calendarValue, onChange: calendarChange })))));
146
+ react_1.default.createElement(Calendar_1.default, { minDate: minDate, maxDate: maxDate, weekStartDay: weekStartDay, locale: locale, badges: badges, displayLeadingZero: displayLeadingZero, resetButtonLabel: resetButtonLabel, todayButtonLabel: todayButtonLabel, markToday: markToday, weekDayLabelFormat: weekDayLabelFormat, heading: heading, subheading: subheading, view: view, views: views, footer: footer, value: currentValue, onChange: calendarChange, overrides: calendarOverrides })))));
133
147
  };
134
148
  exports.default = DatePicker;
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.useDatePickerFormat = void 0;
7
7
  const react_1 = __importDefault(require("react"));
8
8
  const useDatePickerFormat = () => {
9
- const templateValidChars = react_1.default.useMemo(() => ['y', 'Y', 'yy', 'yyyy', 'd', 'dd', 'D', 'm', 'mm'], []);
9
+ const templateValidChars = react_1.default.useMemo(() => ['y', 'Y', 'yy', 'yyyy', 'YYYY', 'd', 'dd', 'D', 'DD', 'm', 'mm'], []);
10
10
  const isValidTemplate = react_1.default.useCallback((template) => {
11
11
  const matches = template.match(/[yYmMdD]+/g);
12
12
  if (!matches) {
@@ -48,12 +48,14 @@ const useDatePickerFormat = () => {
48
48
  template.split('').forEach((char, charIndex) => {
49
49
  switch (char) {
50
50
  case 'y':
51
+ case 'Y':
51
52
  data.years.push(input[charIndex]);
52
53
  break;
53
54
  case 'm':
54
55
  data.months.push(input[charIndex]);
55
56
  break;
56
57
  case 'd':
58
+ case 'D':
57
59
  data.days.push(input[charIndex]);
58
60
  break;
59
61
  default:
@@ -61,8 +63,23 @@ const useDatePickerFormat = () => {
61
63
  break;
62
64
  }
63
65
  });
64
- return new Date(parseInt(data.years.join(''), 10), parseInt(data.months.join(''), 10) - 1, // Fucking month indexes
65
- parseInt(data.days.join(''), 10));
66
+ let year = parseInt(data.years.join(''), 10);
67
+ const month = parseInt(data.months.join(''), 10) - 1; // Fucking month indexes
68
+ const day = parseInt(data.days.join(''), 10);
69
+ if (isNaN(year) || isNaN(month) || isNaN(day)) {
70
+ return null;
71
+ }
72
+ // Two-digit year means the current century (new Date() maps 0-99 to 1900-1999)
73
+ if (data.years.length <= 2) {
74
+ year += 2000;
75
+ }
76
+ const date = new Date(year, month, day);
77
+ date.setFullYear(year);
78
+ // Reject overflowed dates like 31.02 instead of silently moving them to March
79
+ if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
80
+ return null;
81
+ }
82
+ return date;
66
83
  }, [validateTemplate]);
67
84
  /**
68
85
  * Format date to string by template
@@ -44,28 +44,38 @@ const useMasked = () => {
44
44
  if (parsedValue === '') {
45
45
  return data;
46
46
  }
47
+ const requiredCount = mask.filter(pattern => pattern instanceof RegExp).length;
48
+ let filledCount = 0;
47
49
  let patternIndex = 0;
48
50
  for (patternIndex = 0; patternIndex < mask.length; patternIndex++) {
49
- data.isValid = false;
50
51
  if (data.charIndex >= parsedValue.length) {
51
52
  break;
52
53
  }
53
54
  const char = parsedValue[data.charIndex];
54
55
  const pattern = mask[patternIndex];
56
+ // A mask literal (e.g. a shifted separator) got into a RegExp position:
57
+ // drop the char and try the same pattern with the next one, otherwise the rest of input is lost
58
+ if (pattern instanceof RegExp && !new RegExp(pattern).test(char) && mask.includes(char)) {
59
+ if (data.charIndex < (caret || 0)) {
60
+ data.caret -= 1;
61
+ }
62
+ data.charIndex += 1;
63
+ patternIndex -= 1;
64
+ continue;
65
+ }
55
66
  // If char is correct (matched with Regexp pattern)
56
67
  // then accept char into text
57
68
  // and increment the charIndex and caret
58
69
  if (pattern instanceof RegExp && new RegExp(pattern).test(char)) {
59
70
  data.text = `${data.text}${char}`;
60
71
  data.charIndex += 1;
61
- data.isValid = true;
72
+ filledCount += 1;
62
73
  }
63
74
  // If char is correct (equal with pattern string)
64
- // then accept char into text
65
- // and increment the charIndex and caret
75
+ // then accept char into text and increment the charIndex.
76
+ // The caret is not changed: this char is already counted by parseInput
66
77
  if (typeof pattern === 'string' && pattern === char) {
67
78
  data.text = `${data.text}${char}`;
68
- data.caret += 1;
69
79
  data.charIndex += 1;
70
80
  }
71
81
  // If pattern is a string and char is not correct
@@ -81,7 +91,8 @@ const useMasked = () => {
81
91
  const result = {
82
92
  text: data.text,
83
93
  caret: data.caret,
84
- isValid: data.isValid,
94
+ // Valid when every RegExp position of the mask is filled
95
+ isValid: requiredCount > 0 && filledCount === requiredCount,
85
96
  };
86
97
  return result;
87
98
  }, []);
@@ -97,7 +97,7 @@ export interface MenuProps<T, Multiple extends boolean | undefined = undefined>
97
97
  * if the preferred placement doesn't fit in the viewport.
98
98
  * The component will iterate through possible placements until it finds one that fits.
99
99
  *
100
- * **Default**: `true`
100
+ * **Default**: `false`
101
101
  * ```
102
102
  */
103
103
  readonly autoFlip?: boolean;
@@ -115,7 +115,7 @@ export interface MenuProps<T, Multiple extends boolean | undefined = undefined>
115
115
  * - `'absolute'`: Positions relative to the nearest positioned ancestor.
116
116
  * When using 'absolute', make sure a parent element has `position: relative`.
117
117
  *
118
- * **Default**: `'`absolute`'`
118
+ * **Default**: `fixed`
119
119
  * ```
120
120
  */
121
121
  readonly positionStrategy?: PositionStrategy;
@@ -124,7 +124,7 @@ export interface MenuProps<T, Multiple extends boolean | undefined = undefined>
124
124
  * Used to prevent the popper from being positioned too close to the screen boundaries.
125
125
  * The popper will try to flip to another placement if it cannot maintain this margin.
126
126
  *
127
- * **Default**: `8`
127
+ * **Default**: `30`
128
128
  * ```
129
129
  */
130
130
  readonly viewportMargin?: number;
@@ -47,27 +47,24 @@ const isEqual = (a, b) => {
47
47
  };
48
48
  const MenuContainer = react_1.default.forwardRef((props, ref) => {
49
49
  const { items, value, anchorElement = null, overrides, children, closeOutsideClick = true, isOpen = false, anchorPos = 'bottom', alternativePlacements = ['bottom', 'top'], positionStrategy, autoFlip, viewportMargin, offset, multiple = false, autofocus = true, closeOnSelect = !multiple, onRequestClose = onRequestCloseDefault, onAnchorPosChanged, zIndex, onSelectItem, getOptionSelected, maxWidth, } = props;
50
- // const [actualPlacement, setActualPlacement] = React.useState(anchorPos);
51
- const [currentAnchorElement, setAnchorElement] = react_1.default.useState(anchorElement);
52
50
  const overridesMap = react_1.default.useMemo(() => ({
53
51
  List: (overrides === null || overrides === void 0 ? void 0 : overrides.List) || MenuList_1.default,
54
52
  Popper: (overrides === null || overrides === void 0 ? void 0 : overrides.Popper) || Popper_1.default,
55
53
  }), [overrides === null || overrides === void 0 ? void 0 : overrides.List, overrides === null || overrides === void 0 ? void 0 : overrides.Popper]);
56
54
  const menuListRef = react_1.default.useRef(null);
57
55
  const menuPopperRef = react_1.default.useRef(null);
58
- const isOpenRef = react_1.default.useRef(isOpen);
56
+ // Starts as `false` so that a menu mounted in the open state is focused and scrolled too
57
+ const isOpenRef = react_1.default.useRef(false);
59
58
  const focusTimeoutRef = react_1.default.useRef();
60
59
  const { dispatch, state: { selectedIndexes, markedIndex, hoveredIndex }, } = (0, context_1.default)();
61
60
  const selectedIndexesRef = react_1.default.useRef(selectedIndexes);
61
+ const compareFunc = react_1.default.useCallback((item, val) => getOptionSelected ? getOptionSelected({ item, value: val }) : isEqual(item, val), [getOptionSelected]);
62
62
  // Оптимизированная функция получения выбранных индексов
63
63
  const getSelectedIndexes = react_1.default.useCallback(() => {
64
64
  if (value === null || (multiple && value.length === 0)) {
65
65
  return [];
66
66
  }
67
67
  const indexes = [];
68
- const compareFunc = getOptionSelected
69
- ? (item, val) => getOptionSelected({ item, value: val })
70
- : (item, val) => isEqual(item, val);
71
68
  if (multiple) {
72
69
  const valueArray = value;
73
70
  items.forEach((item, index) => {
@@ -84,7 +81,7 @@ const MenuContainer = react_1.default.forwardRef((props, ref) => {
84
81
  }
85
82
  }
86
83
  return indexes;
87
- }, [items, value, multiple, getOptionSelected]);
84
+ }, [items, value, multiple, compareFunc]);
88
85
  const scrollToIndex = react_1.default.useCallback((index) => {
89
86
  if (index === -1) {
90
87
  return;
@@ -94,20 +91,24 @@ const MenuContainer = react_1.default.forwardRef((props, ref) => {
94
91
  if (container && option) {
95
92
  const containerRect = container.getBoundingClientRect();
96
93
  const optionRect = option.getBoundingClientRect();
97
- const scrollOffset = optionRect.top - containerRect.top + container.scrollTop;
98
- container.scrollTo({
99
- top: scrollOffset,
100
- behavior: 'instant'
101
- });
94
+ // Scroll only when the option is out of the visible area
95
+ if (optionRect.top < containerRect.top) {
96
+ container.scrollTop -= containerRect.top - optionRect.top;
97
+ }
98
+ else if (optionRect.bottom > containerRect.bottom) {
99
+ container.scrollTop += optionRect.bottom - containerRect.bottom;
100
+ }
102
101
  }
103
102
  }, []);
104
103
  const scrollToFirstSelected = react_1.default.useCallback(() => {
105
- if (selectedIndexes.length === 0)
104
+ // Computed from the props, the context state may not be updated yet
105
+ const indexes = getSelectedIndexes();
106
+ if (indexes.length === 0)
106
107
  return;
107
- const firstSelectedIndex = Math.min(...selectedIndexes);
108
+ const firstSelectedIndex = Math.min(...indexes);
108
109
  dispatch((0, context_1.actionSetmenuState)({ markedIndex: firstSelectedIndex }));
109
110
  scrollToIndex(firstSelectedIndex);
110
- }, [dispatch, selectedIndexes, scrollToIndex]);
111
+ }, [dispatch, getSelectedIndexes, scrollToIndex]);
111
112
  const selectItem = react_1.default.useCallback((index) => {
112
113
  if (index < 0 || index >= items.length)
113
114
  return;
@@ -117,17 +118,13 @@ const MenuContainer = react_1.default.forwardRef((props, ref) => {
117
118
  if (typeof onSelectItem === 'function') {
118
119
  if (multiple) {
119
120
  // Для multiple режима
120
- const selectedSet = new Set();
121
- if (value !== null) {
122
- value.forEach(v => selectedSet.add(v));
123
- }
124
- if (selectedIndexes.includes(index)) {
125
- selectedSet.delete(item);
126
- }
127
- else {
128
- selectedSet.add(item);
129
- }
130
- onSelectItem(Array.from(selectedSet));
121
+ // Value entries may be other instances than items, so compare them by compareFunc
122
+ const currentValue = value || [];
123
+ const isSelected = currentValue.some(v => compareFunc(item, v));
124
+ const newValue = isSelected
125
+ ? currentValue.filter(v => !compareFunc(item, v))
126
+ : [...currentValue, item];
127
+ onSelectItem(newValue);
131
128
  }
132
129
  else {
133
130
  // Для single режима
@@ -137,7 +134,7 @@ const MenuContainer = react_1.default.forwardRef((props, ref) => {
137
134
  onRequestClose();
138
135
  }
139
136
  }
140
- }, [onSelectItem, onRequestClose, selectedIndexes, closeOnSelect, items, value, multiple]);
137
+ }, [onSelectItem, onRequestClose, compareFunc, closeOnSelect, items, value, multiple]);
141
138
  const highlightIndex = react_1.default.useCallback((index) => {
142
139
  const validIndex = Math.max(-1, Math.min(index, items.length - 1));
143
140
  if (validIndex !== markedIndex) {
@@ -189,12 +186,6 @@ const MenuContainer = react_1.default.forwardRef((props, ref) => {
189
186
  scrollToFirstSelected,
190
187
  selectItem,
191
188
  ]);
192
- // Обновление anchor элемента
193
- react_1.default.useEffect(() => {
194
- if (anchorElement !== currentAnchorElement) {
195
- setAnchorElement(anchorElement);
196
- }
197
- }, [anchorElement, currentAnchorElement]);
198
189
  // Обработчик клавиатуры
199
190
  const listKeydownEvent = react_1.default.useCallback((event) => {
200
191
  switch (event.code) {
@@ -244,7 +235,7 @@ const MenuContainer = react_1.default.forwardRef((props, ref) => {
244
235
  return;
245
236
  isOpenRef.current = isOpen;
246
237
  if (!isOpen) {
247
- onRequestClose();
238
+ // The menu is already closed by the parent, so onRequestClose must not be called again
248
239
  dispatch((0, context_1.actionSetmenuState)({ markedIndex: -1, hoveredIndex: -1 }));
249
240
  return;
250
241
  }
@@ -260,7 +251,7 @@ const MenuContainer = react_1.default.forwardRef((props, ref) => {
260
251
  (_a = menuListRef.current) === null || _a === void 0 ? void 0 : _a.focus();
261
252
  }, 15);
262
253
  }
263
- }, [isOpen, autofocus, dispatch, onRequestClose, scrollToFirstSelected]);
254
+ }, [isOpen, autofocus, dispatch, scrollToFirstSelected]);
264
255
  // Обновление выбранных индексов
265
256
  react_1.default.useEffect(() => {
266
257
  const indexes = getSelectedIndexes();
@@ -21,8 +21,6 @@ const react_1 = __importDefault(require("react"));
21
21
  const AutoHeightWrapper_1 = __importDefault(require("./AutoHeightWrapper"));
22
22
  const VirtualizedListComponent_1 = __importDefault(require("./VirtualizedListComponent"));
23
23
  __exportStar(require("./VirtualizedListComponent"), exports);
24
- const VirtualizedList = react_1.default.forwardRef((props, ref) => {
25
- return (react_1.default.createElement(AutoHeightWrapper_1.default, null, height => height > 0 && react_1.default.createElement(VirtualizedListComponent_1.default, Object.assign({}, props, { height: height, ref: ref }))));
26
- });
24
+ const VirtualizedList = react_1.default.forwardRef((props, ref) => (react_1.default.createElement(AutoHeightWrapper_1.default, null, height => height > 0 && react_1.default.createElement(VirtualizedListComponent_1.default, Object.assign({}, props, { height: height, ref: ref })))));
27
25
  VirtualizedList.displayName = 'VirtualizedList';
28
26
  exports.default = VirtualizedList;
@@ -7,12 +7,17 @@ const react_1 = __importDefault(require("react"));
7
7
  const react_2 = require("@emotion/react");
8
8
  const context_1 = require("./context");
9
9
  const TabManager_1 = __importDefault(require("../../utils/TabManager"));
10
+ /**
11
+ * Count of the modals which lock the body scroll
12
+ */
13
+ let bodyLocksCount = 0;
10
14
  const ModalWrapper = props => {
11
15
  const { children, isOpen: isOpenProp, autofocus = true } = props;
12
16
  const { state, dispatch } = (0, context_1.useContext)();
13
- const initialOpenStateRef = react_1.default.useRef(isOpenProp);
14
- const [alreadyMounted, setMountState] = react_1.default.useState(true);
15
17
  const { closeOnEscape, isMounted, isOpen, destroyTimeout, onRequestClose } = state;
18
+ const timerRef = react_1.default.useRef(null);
19
+ const bodyLockedRef = react_1.default.useRef(false);
20
+ const isFirstRunRef = react_1.default.useRef(true);
16
21
  const containerRef = react_1.default.useRef(null);
17
22
  const id = context_1.PORTAL_ID + react_1.default.useId();
18
23
  react_1.default.useEffect(() => {
@@ -53,13 +58,34 @@ const ModalWrapper = props => {
53
58
  (_a = outer.parentNode) === null || _a === void 0 ? void 0 : _a.removeChild(outer);
54
59
  return widthNoScroll - widthWithScroll;
55
60
  }, []);
61
+ const lockBody = react_1.default.useCallback(() => {
62
+ var _a;
63
+ if (!bodyLockedRef.current) {
64
+ bodyLockedRef.current = true;
65
+ bodyLocksCount += 1;
66
+ (_a = window.document.body) === null || _a === void 0 ? void 0 : _a.classList.add('-modal-over');
67
+ }
68
+ }, []);
69
+ const unlockBody = react_1.default.useCallback(() => {
70
+ var _a;
71
+ if (bodyLockedRef.current) {
72
+ bodyLockedRef.current = false;
73
+ bodyLocksCount = Math.max(0, bodyLocksCount - 1);
74
+ if (bodyLocksCount === 0) {
75
+ (_a = window.document.body) === null || _a === void 0 ? void 0 : _a.classList.remove('-modal-over');
76
+ }
77
+ }
78
+ }, []);
56
79
  /**
57
80
  * Component will unmount
58
- * Mark component as unmounted
81
+ * Clear pending timers and unlock the body scroll even if the modal is still open
59
82
  */
60
83
  react_1.default.useEffect(() => () => {
61
- setMountState(false);
62
- }, []);
84
+ if (timerRef.current) {
85
+ clearTimeout(timerRef.current);
86
+ }
87
+ unlockBody();
88
+ }, [unlockBody]);
63
89
  react_1.default.useEffect(() => {
64
90
  /**
65
91
  * The last dialog will be found and closed
@@ -101,40 +127,41 @@ const ModalWrapper = props => {
101
127
  * and sets visibility property to false otherwise
102
128
  */
103
129
  react_1.default.useEffect(() => {
104
- var _a;
105
- if (isOpen !== isOpenProp || initialOpenStateRef.current) {
106
- /**
107
- * Mark as mounted and wait minimum loop of event (15ms) then mark as open
108
- */
109
- if (isOpenProp) {
110
- dispatch((0, context_1.actionSetState)({ isMounted: true }));
111
- (_a = window.document.body) === null || _a === void 0 ? void 0 : _a.classList.add('-modal-over');
112
- setTimeout(() => {
113
- if (alreadyMounted) {
114
- dispatch((0, context_1.actionSetState)({ isOpen: true }));
115
- }
116
- }, 120);
117
- return;
118
- }
119
- /**
120
- * Mark as closed and wait destroyTimeout then mark as unmounted
121
- */
122
- if (!isOpenProp) {
123
- dispatch((0, context_1.actionSetState)({ isOpen: false }));
124
- setTimeout(() => {
125
- var _a, _b;
126
- if (alreadyMounted) {
127
- const portal = window.document.querySelector(`#${context_1.PORTAL_ID}`);
128
- if (((_a = portal === null || portal === void 0 ? void 0 : portal.children) === null || _a === void 0 ? void 0 : _a.length) === 1) {
129
- (_b = window.document.body) === null || _b === void 0 ? void 0 : _b.classList.remove('-modal-over');
130
- }
131
- dispatch((0, context_1.actionSetState)({ isMounted: false }));
132
- }
133
- }, destroyTimeout);
134
- return;
135
- }
130
+ const isFirstRun = isFirstRunRef.current;
131
+ isFirstRunRef.current = false;
132
+ // Nothing to close on mount
133
+ if (isFirstRun && !isOpenProp) {
134
+ return;
136
135
  }
137
- }, [isOpenProp, isOpen, alreadyMounted, destroyTimeout, dispatch, getScrollWidth]);
136
+ // Previous transition (open or close) must not finish after the new one started
137
+ if (timerRef.current) {
138
+ clearTimeout(timerRef.current);
139
+ timerRef.current = null;
140
+ }
141
+ /**
142
+ * Mark as mounted and wait minimum loop of event then mark as open
143
+ */
144
+ if (isOpenProp) {
145
+ dispatch((0, context_1.actionSetState)({ isMounted: true }));
146
+ lockBody();
147
+ timerRef.current = setTimeout(() => {
148
+ timerRef.current = null;
149
+ dispatch((0, context_1.actionSetState)({ isOpen: true }));
150
+ }, 120);
151
+ return;
152
+ }
153
+ /**
154
+ * Mark as closed and wait destroyTimeout then mark as unmounted
155
+ */
156
+ dispatch((0, context_1.actionSetState)({ isOpen: false }));
157
+ timerRef.current = setTimeout(() => {
158
+ timerRef.current = null;
159
+ unlockBody();
160
+ dispatch((0, context_1.actionSetState)({ isMounted: false }));
161
+ }, destroyTimeout);
162
+ // destroyTimeout is read at the moment of closing
163
+ // eslint-disable-next-line react-hooks/exhaustive-deps
164
+ }, [isOpenProp, dispatch, lockBody, unlockBody]);
138
165
  return react_1.default.useMemo(() => isMounted ? (react_1.default.createElement("div", { ref: containerRef, tabIndex: -1, id: id },
139
166
  children,
140
167
  react_1.default.createElement(react_2.Global, { styles: (0, react_2.css) `
@@ -31,6 +31,12 @@ const context = react_1.default.createContext({ state: exports.defaultState, dis
31
31
  const ContextProvider = props => {
32
32
  const { children, initialState } = props;
33
33
  const [state, dispatch] = react_1.default.useReducer(reducer, Object.assign(Object.assign({}, exports.defaultState), initialState));
34
+ // Keep the props up to date (isOpen is managed by ModalWrapper itself),
35
+ // otherwise a stale onRequestClose callback would be called
36
+ const { onRequestClose, closeOnEscape, closeOnOverlayClick, destroyTimeout } = initialState;
37
+ react_1.default.useEffect(() => {
38
+ dispatch((0, exports.actionSetState)({ onRequestClose, closeOnEscape, closeOnOverlayClick, destroyTimeout }));
39
+ }, [onRequestClose, closeOnEscape, closeOnOverlayClick, destroyTimeout]);
34
40
  return react_1.default.createElement(context.Provider, { value: { state, dispatch } }, children);
35
41
  };
36
42
  exports.ContextProvider = ContextProvider;
@@ -14,7 +14,6 @@ const RenderModal_1 = __importDefault(require("./RenderModal"));
14
14
  const NoSSR_1 = __importDefault(require("../../NoSSR"));
15
15
  const BaseModal = props => {
16
16
  const { isOpen, children, overrides, autofocus = true, closeOnOverlayClick = context_1.defaultState.closeOnOverlayClick, closeOnEscape = context_1.defaultState.closeOnEscape, destroyTimeout = context_1.defaultState.destroyTimeout, onRequestClose, } = props;
17
- const destroyTimeoutRef = react_1.default.useRef(null);
18
17
  const [domLoaded, setDomLoaded] = react_1.default.useState(false);
19
18
  const overridesMap = react_1.default.useMemo(() => ({
20
19
  Overlay: (overrides === null || overrides === void 0 ? void 0 : overrides.Overlay) || ModalOverlay_1.default,
@@ -26,13 +25,6 @@ const BaseModal = props => {
26
25
  * Client render detection
27
26
  */
28
27
  setDomLoaded(true);
29
- const timeout = destroyTimeoutRef.current;
30
- // unload destroy timeout
31
- return () => {
32
- if (timeout) {
33
- clearTimeout(timeout);
34
- }
35
- };
36
28
  }, []);
37
29
  const portalEl = react_1.default.useMemo(() => {
38
30
  if (typeof window === 'undefined') {
@@ -56,7 +56,7 @@ export interface PopperProps extends React.HTMLAttributes<HTMLDivElement> {
56
56
  * if the preferred placement doesn't fit in the viewport.
57
57
  * The component will iterate through possible placements until it finds one that fits.
58
58
  *
59
- * @default true
59
+ * @default false
60
60
  * @example
61
61
  * ```tsx
62
62
  * <Popper autoFlip={true}>...</Popper> // Will flip if needed
@@ -95,7 +95,7 @@ export interface PopperProps extends React.HTMLAttributes<HTMLDivElement> {
95
95
  * Used to prevent the popper from being positioned too close to the screen boundaries.
96
96
  * The popper will try to flip to another placement if it cannot maintain this margin.
97
97
  *
98
- * @default 8
98
+ * @default 30
99
99
  * @example
100
100
  * ```tsx
101
101
  * // Larger margin for better visibility