@invoicebox/ui 0.1.0-alpha.322 → 0.1.0-alpha.325

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.
Files changed (29) hide show
  1. package/dist/components/form/CountrySelect/components/CountryItem/index.js +3 -1
  2. package/dist/components/form/CountrySelect/index.d.ts +6 -0
  3. package/dist/components/form/CountrySelect/index.js +15 -4
  4. package/dist/components/form/CountrySelect/index.test.d.ts +1 -0
  5. package/dist/components/form/CountrySelect/index.test.js +85 -0
  6. package/dist/components/form/DateInput/index.d.ts +1 -1
  7. package/dist/components/form/DateInput/index.js +7 -1
  8. package/dist/components/form/DateInput/index.test.d.ts +1 -0
  9. package/dist/components/form/DateInput/index.test.js +71 -0
  10. package/dist/components/form/PhoneInput/index.d.ts +1 -1
  11. package/dist/components/form/PhoneInput/index.js +2 -1
  12. package/dist/components/form/PositiveIntegerInput/index.d.ts +4 -0
  13. package/dist/components/form/PositiveIntegerInput/index.js +46 -6
  14. package/dist/components/form/PositiveIntegerInput/index.test.d.ts +1 -0
  15. package/dist/components/form/PositiveIntegerInput/index.test.js +81 -0
  16. package/dist/components/form/PositiveIntegerInput/styles.d.ts +1 -3
  17. package/dist/components/form/PositiveIntegerInput/styles.js +8 -10
  18. package/dist/components/form/Select/index.js +23 -6
  19. package/dist/esm/components/form/CountrySelect/components/CountryItem/index.js +3 -1
  20. package/dist/esm/components/form/CountrySelect/index.js +16 -5
  21. package/dist/esm/components/form/CountrySelect/index.test.js +82 -0
  22. package/dist/esm/components/form/DateInput/index.js +7 -1
  23. package/dist/esm/components/form/DateInput/index.test.js +69 -0
  24. package/dist/esm/components/form/PhoneInput/index.js +2 -1
  25. package/dist/esm/components/form/PositiveIntegerInput/index.js +46 -6
  26. package/dist/esm/components/form/PositiveIntegerInput/index.test.js +78 -0
  27. package/dist/esm/components/form/PositiveIntegerInput/styles.js +7 -7
  28. package/dist/esm/components/form/Select/index.js +23 -6
  29. package/package.json +1 -1
@@ -27,7 +27,9 @@ var CountryItem = exports.CountryItem = function CountryItem(_ref) {
27
27
  return /*#__PURE__*/_react["default"].createElement(S.Wrapper, {
28
28
  $palette: palette,
29
29
  onClick: handleSelect,
30
- type: "button"
30
+ type: "button",
31
+ role: "option",
32
+ "aria-selected": isSelected
31
33
  }, /*#__PURE__*/_react["default"].createElement(S.Title, {
32
34
  $isSelected: isSelected,
33
35
  $palette: palette
@@ -17,6 +17,12 @@ type TFieldProps = {
17
17
  type TControlProps<T> = Pick<TSearchInputProps, 'placeholder'> & Pick<TCountryItemProps<T>, 'selectedLabel'> & {
18
18
  options: TOption[];
19
19
  disabled?: boolean;
20
+ /**
21
+ * Только для скринридеров, на экране не выводится: начало aria-label кнопки выбора страны.
22
+ * Итоговое имя — «<buttonAriaLabelPrefix>: <название выбранной страны>», например
23
+ * «Код страны: Россия». Кнопка показывает только флаг, без имени её не понять (WCAG 4.1.2).
24
+ */
25
+ buttonAriaLabelPrefix?: string;
20
26
  } & Pick<TDropdownProps, 'positionVertical' | 'positionLeft' | 'positionRight' | 'minWidth' | 'width' | 'isAutoPosition' | 'isTopPosition'>;
21
27
  export type TProps = TFieldProps & TControlProps<string>;
22
28
  export declare const CountrySelect: FC<TProps>;
@@ -16,7 +16,7 @@ var _Arrow = require("../../common/Arrow");
16
16
  var _Dropdown = require("../../common/Dropdown");
17
17
  var _Scrollbar = require("../../common/Scrollbar");
18
18
  var _palette = require("../../../palette");
19
- var _excluded = ["value", "onChange", "options", "placeholder", "disabled", "selectedLabel"];
19
+ var _excluded = ["value", "onChange", "options", "placeholder", "disabled", "selectedLabel", "buttonAriaLabelPrefix"];
20
20
  function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { return e ? t : r; })(e); }
21
21
  function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != _typeof(e) && "function" != typeof e) return { "default": e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n["default"] = e, t && t.set(e, n), n; }
22
22
  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); }
@@ -40,7 +40,10 @@ var CountrySelect = exports.CountrySelect = function CountrySelect(_ref) {
40
40
  _ref$disabled = _ref.disabled,
41
41
  disabled = _ref$disabled === void 0 ? false : _ref$disabled,
42
42
  selectedLabel = _ref.selectedLabel,
43
+ _ref$buttonAriaLabelP = _ref.buttonAriaLabelPrefix,
44
+ buttonAriaLabelPrefix = _ref$buttonAriaLabelP === void 0 ? 'Код страны' : _ref$buttonAriaLabelP,
43
45
  dropdownPositionProps = _objectWithoutProperties(_ref, _excluded);
46
+ var listboxId = (0, _react.useId)();
44
47
  var palette = (0, _palette.useComponentPalette)('countrySelect');
45
48
  var _useState = (0, _react.useState)(false),
46
49
  _useState2 = _slicedToArray(_useState, 2),
@@ -87,8 +90,12 @@ var CountrySelect = exports.CountrySelect = function CountrySelect(_ref) {
87
90
  }, /*#__PURE__*/_react["default"].createElement(S.HeaderWrapper, {
88
91
  type: "button",
89
92
  onClick: handleDropdownTrigger,
90
- disabled: disabled
91
- }, selectedOption.flag && /*#__PURE__*/_react["default"].createElement(_Flag.Flag, {
93
+ disabled: disabled,
94
+ "aria-label": selectedOption ? "".concat(buttonAriaLabelPrefix, ": ").concat(selectedOption.label) : buttonAriaLabelPrefix,
95
+ "aria-haspopup": "listbox",
96
+ "aria-expanded": isOpen,
97
+ "aria-controls": isOpen ? listboxId : undefined
98
+ }, (selectedOption === null || selectedOption === void 0 ? void 0 : selectedOption.flag) && /*#__PURE__*/_react["default"].createElement(_Flag.Flag, {
92
99
  isSmall: false,
93
100
  flag: selectedOption.flag
94
101
  }), !disabled && /*#__PURE__*/_react["default"].createElement(_Arrow.Arrow, {
@@ -108,6 +115,10 @@ var CountrySelect = exports.CountrySelect = function CountrySelect(_ref) {
108
115
  value: search
109
116
  })), /*#__PURE__*/_react["default"].createElement(_Scrollbar.Scrollbar, {
110
117
  maxHeight: 192
118
+ }, /*#__PURE__*/_react["default"].createElement("div", {
119
+ role: "listbox",
120
+ id: listboxId,
121
+ "aria-label": buttonAriaLabelPrefix
111
122
  }, filtredOptions.map(function (option) {
112
123
  return /*#__PURE__*/_react["default"].createElement(_CountryItem.CountryItem, {
113
124
  key: option.value,
@@ -121,5 +132,5 @@ var CountrySelect = exports.CountrySelect = function CountrySelect(_ref) {
121
132
  hint: option.hint,
122
133
  flag: option.flag
123
134
  });
124
- })))));
135
+ }))))));
125
136
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+
3
+ var _react = _interopRequireDefault(require("react"));
4
+ var _server = require("react-dom/server");
5
+ var _ = require(".");
6
+ var _PhoneInput = require("../PhoneInput");
7
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }
8
+ var options = [{
9
+ label: 'Россия',
10
+ value: 'RUS',
11
+ description: '+7',
12
+ flag: 'RUS'
13
+ }, {
14
+ label: 'Казахстан',
15
+ value: 'KAZ',
16
+ description: '+7',
17
+ flag: 'KAZ'
18
+ }];
19
+
20
+ // Серверный рендер: предупреждения про useLayoutEffect (Dropdown) ожидаемы и к проверке не относятся
21
+ // eslint-disable-next-line no-console
22
+ var originalError = console.error;
23
+ beforeAll(function () {
24
+ jest.spyOn(console, 'error').mockImplementation(function (message) {
25
+ if (typeof message === 'string' && message.includes('useLayoutEffect does nothing on the server')) return;
26
+ // eslint-disable-next-line no-console
27
+ for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
28
+ rest[_key - 1] = arguments[_key];
29
+ }
30
+ originalError.apply(void 0, [message].concat(rest));
31
+ });
32
+ });
33
+ afterAll(function () {
34
+ return jest.restoreAllMocks();
35
+ });
36
+ var noop = jest.fn();
37
+ var renderButton = function renderButton(markup) {
38
+ var match = markup.match(/<button[^>]*aria-haspopup[^>]*>/);
39
+ if (!match) throw new Error("\u041A\u043D\u043E\u043F\u043A\u0430 \u0432\u044B\u0431\u043E\u0440\u0430 \u0441\u0442\u0440\u0430\u043D\u044B \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430: ".concat(markup));
40
+ return match[0];
41
+ };
42
+ describe('CountrySelect: доступное имя и состояние кнопки (A11Y-12)', function () {
43
+ test('имя по умолчанию — «Код страны: <страна>», свёрнут, раскрывает listbox', function () {
44
+ var button = renderButton((0, _server.renderToStaticMarkup)(/*#__PURE__*/_react["default"].createElement(_.CountrySelect, {
45
+ value: "KAZ",
46
+ onChange: noop,
47
+ options: options,
48
+ selectedLabel: ""
49
+ })));
50
+ expect(button).toContain('aria-label="Код страны: Казахстан"');
51
+ expect(button).toContain('aria-haspopup="listbox"');
52
+ expect(button).toContain('aria-expanded="false"');
53
+ expect(button).not.toContain('aria-controls');
54
+ });
55
+ test('префикс имени задаётся пропом buttonAriaLabelPrefix', function () {
56
+ var button = renderButton((0, _server.renderToStaticMarkup)(/*#__PURE__*/_react["default"].createElement(_.CountrySelect, {
57
+ value: "RUS",
58
+ onChange: noop,
59
+ options: options,
60
+ selectedLabel: "",
61
+ buttonAriaLabelPrefix: "Country code"
62
+ })));
63
+ expect(button).toContain('aria-label="Country code: Россия"');
64
+ });
65
+ test('PhoneInput пробрасывает buttonAriaLabelPrefix из countrySelectProps', function () {
66
+ var button = renderButton((0, _server.renderToStaticMarkup)(/*#__PURE__*/_react["default"].createElement(_PhoneInput.PhoneInput, {
67
+ label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D",
68
+ value: "",
69
+ onChange: noop,
70
+ countries: [{
71
+ label: 'Russia',
72
+ value: 'RUS'
73
+ }, {
74
+ label: 'Belarus',
75
+ value: 'BLR'
76
+ }],
77
+ countrySelectProps: {
78
+ selectedLabel: '',
79
+ placeholder: '',
80
+ buttonAriaLabelPrefix: 'Country code'
81
+ }
82
+ })));
83
+ expect(button).toContain('aria-label="Country code: Russia"');
84
+ });
85
+ });
@@ -11,7 +11,7 @@ export type TProps = {
11
11
  dropdownProps?: Pick<TDropdownProps, 'isAutoPosition' | 'positionLeft' | 'positionRight' | 'positionVertical'>;
12
12
  useModernStyles?: boolean;
13
13
  required?: boolean;
14
- } & Pick<TPureInputProps, 'hasError' | 'name' | 'onBlur' | 'onFocus' | 'id'> & Pick<TInputLabelProps, 'label'> & Pick<TCalendarProps, 'maxDate' | 'minDate'> & {
14
+ } & Pick<TPureInputProps, 'hasError' | 'name' | 'onBlur' | 'onFocus' | 'id' | 'inputMode'> & Pick<TInputLabelProps, 'label'> & Pick<TCalendarProps, 'maxDate' | 'minDate'> & {
15
15
  size?: TSizes;
16
16
  };
17
17
  export declare const DateInput: FC<TProps>;
@@ -46,12 +46,17 @@ var DateInput = exports.DateInput = function DateInput(_ref) {
46
46
  useModernStyles = _ref$useModernStyles === void 0 ? false : _ref$useModernStyles,
47
47
  _ref$required = _ref.required,
48
48
  required = _ref$required === void 0 ? false : _ref$required,
49
- id = _ref.id;
49
+ id = _ref.id,
50
+ inputMode = _ref.inputMode;
50
51
  var palette = (0, _palette.useComponentPalette)('dateInput');
51
52
  var inputRef = (0, _react.useRef)(null);
52
53
  var fallbackId = (0, _react.useId)();
53
54
  var inputId = id !== null && id !== void 0 ? id : fallbackId;
54
55
  var isMobile = (0, _index.useMobile)();
56
+ // На мобильном тап по полю открывает календарь в шторке — экранная клавиатура поверх него
57
+ // не нужна (MOB-8). Ввод с клавиатуры остаётся (физическая клавиатура, вставка); проп
58
+ // inputMode переопределяет значение, например 'none' для всех тач-экранов
59
+ var fieldInputMode = inputMode !== null && inputMode !== void 0 ? inputMode : isMobile ? 'none' : undefined;
55
60
  var _useState = (0, _react.useState)(false),
56
61
  _useState2 = _slicedToArray(_useState, 2),
57
62
  isOpen = _useState2[0],
@@ -142,6 +147,7 @@ var DateInput = exports.DateInput = function DateInput(_ref) {
142
147
  onBlur: handleBlur,
143
148
  value: stringValue,
144
149
  onChange: handleStringValueChange,
150
+ inputMode: fieldInputMode,
145
151
  paddingRight: 44,
146
152
  useModernStyles: useModernStyles
147
153
  }, paddingAndVariantOptions)), /*#__PURE__*/_react["default"].createElement(S.Icon, {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+
3
+ var _react = _interopRequireDefault(require("react"));
4
+ var _server = require("react-dom/server");
5
+ var _ = require(".");
6
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }
7
+ 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); }
8
+ // Серверный рендер: предупреждения про useLayoutEffect (Dropdown) ожидаемы и к проверке не относятся
9
+ // eslint-disable-next-line no-console
10
+ var originalError = console.error;
11
+ beforeAll(function () {
12
+ jest.spyOn(console, 'error').mockImplementation(function (message) {
13
+ if (typeof message === 'string' && message.includes('useLayoutEffect does nothing on the server')) return;
14
+ // eslint-disable-next-line no-console
15
+ for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
16
+ rest[_key - 1] = arguments[_key];
17
+ }
18
+ originalError.apply(void 0, [message].concat(rest));
19
+ });
20
+ });
21
+ afterAll(function () {
22
+ return jest.restoreAllMocks();
23
+ });
24
+ var noop = jest.fn();
25
+
26
+ // useMobile читает window.matchMedia при первом рендере
27
+ var withViewport = function withViewport(isMobile, render) {
28
+ var globalWithWindow = globalThis;
29
+ var previous = globalWithWindow.window;
30
+ globalWithWindow.window = {
31
+ matchMedia: function matchMedia() {
32
+ return {
33
+ matches: isMobile
34
+ };
35
+ }
36
+ };
37
+ try {
38
+ return render();
39
+ } finally {
40
+ globalWithWindow.window = previous;
41
+ }
42
+ };
43
+ var renderInput = function renderInput(isMobile) {
44
+ var _markup$match$, _markup$match;
45
+ var props = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
46
+ var markup = withViewport(isMobile, function () {
47
+ return (0, _server.renderToStaticMarkup)(/*#__PURE__*/_react["default"].createElement(_.DateInput, _extends({
48
+ id: "date",
49
+ label: "\u0414\u0430\u0442\u0430",
50
+ value: null,
51
+ onChange: noop
52
+ }, props)));
53
+ });
54
+ return (_markup$match$ = (_markup$match = markup.match(/<input[^>]*>/)) === null || _markup$match === void 0 ? void 0 : _markup$match[0]) !== null && _markup$match$ !== void 0 ? _markup$match$ : '';
55
+ };
56
+ describe('DateInput: экранная клавиатура (MOB-8)', function () {
57
+ test('на мобильном — inputmode="none": тап открывает только календарь', function () {
58
+ expect(renderInput(true)).toContain('inputMode="none"');
59
+ });
60
+ test('на десктопе inputmode не задан', function () {
61
+ expect(renderInput(false)).not.toContain('inputMode');
62
+ });
63
+ test('проп inputMode переопределяет значение по умолчанию', function () {
64
+ expect(renderInput(false, {
65
+ inputMode: 'none'
66
+ })).toContain('inputMode="none"');
67
+ expect(renderInput(true, {
68
+ inputMode: 'numeric'
69
+ })).toContain('inputMode="numeric"');
70
+ });
71
+ });
@@ -10,7 +10,7 @@ type TFieldProps = Pick<TPureInputProps, 'name' | 'onBlur' | 'onFocus'> & {
10
10
  };
11
11
  type TControlProps = Pick<TPureInputProps, 'disabled' | 'id' | 'autoFocus' | 'useModernStyles'> & {
12
12
  label: string;
13
- countrySelectProps?: Pick<TCountrySelectProps, 'selectedLabel' | 'placeholder'>;
13
+ countrySelectProps?: Pick<TCountrySelectProps, 'selectedLabel' | 'placeholder' | 'buttonAriaLabelPrefix'>;
14
14
  onCountryChange?: (countryCode: string) => void;
15
15
  countries?: Array<{
16
16
  label: string;
@@ -212,7 +212,8 @@ var PhoneInput = exports.PhoneInput = function PhoneInput(_ref) {
212
212
  positionRight: "0",
213
213
  positionLeft: "-20px",
214
214
  disabled: disabled,
215
- selectedLabel: (countrySelectProps === null || countrySelectProps === void 0 ? void 0 : countrySelectProps.selectedLabel) || ''
215
+ selectedLabel: (countrySelectProps === null || countrySelectProps === void 0 ? void 0 : countrySelectProps.selectedLabel) || '',
216
+ buttonAriaLabelPrefix: countrySelectProps === null || countrySelectProps === void 0 ? void 0 : countrySelectProps.buttonAriaLabelPrefix
216
217
  }))), /*#__PURE__*/_react["default"].createElement(_reactInputMask["default"], {
217
218
  value: inputValue,
218
219
  onFocus: focusHandler,
@@ -7,6 +7,10 @@ export type TProps = {
7
7
  onChange: (value: number | null) => void;
8
8
  max?: number;
9
9
  upAndDown?: boolean;
10
+ /** Только для скринридеров и голосового управления, на экране не выводится: aria-label кнопки «вверх» */
11
+ incrementButtonAriaLabel?: string;
12
+ /** Только для скринридеров и голосового управления, на экране не выводится: aria-label кнопки «вниз» */
13
+ decrementButtonAriaLabel?: string;
10
14
  } & Pick<TInputLabelProps, 'label'> & Pick<TPureInputProps, 'placeholder' | 'hasError' | 'onFocus' | 'onBlur' | 'disabled' | 'name' | 'useModernStyles' | 'id'> & {
11
15
  size?: TSizes;
12
16
  };
@@ -35,7 +35,13 @@ var PositiveIntegerInput = exports.PositiveIntegerInput = function PositiveInteg
35
35
  size = _ref.size,
36
36
  _ref$useModernStyles = _ref.useModernStyles,
37
37
  useModernStyles = _ref$useModernStyles === void 0 ? false : _ref$useModernStyles,
38
- id = _ref.id;
38
+ id = _ref.id,
39
+ _ref$disabled = _ref.disabled,
40
+ disabled = _ref$disabled === void 0 ? false : _ref$disabled,
41
+ _ref$incrementButtonA = _ref.incrementButtonAriaLabel,
42
+ incrementButtonAriaLabel = _ref$incrementButtonA === void 0 ? 'Увеличить' : _ref$incrementButtonA,
43
+ _ref$decrementButtonA = _ref.decrementButtonAriaLabel,
44
+ decrementButtonAriaLabel = _ref$decrementButtonA === void 0 ? 'Уменьшить' : _ref$decrementButtonA;
39
45
  var _useInputFocus = (0, _useInputFocus2.useInputFocus)({
40
46
  onFocus: onFocus,
41
47
  onBlur: onBlur
@@ -85,12 +91,32 @@ var PositiveIntegerInput = exports.PositiveIntegerInput = function PositiveInteg
85
91
  var handleDown = (0, _react.useCallback)(function () {
86
92
  onChange(decrement(value));
87
93
  }, [decrement, value, onChange]);
94
+ var isUpDisabled = disabled || isIncrementDisabled;
95
+ var isDownDisabled = disabled || isDecrementDisabled;
96
+
97
+ // Стрелки клавиатуры в поле — как кнопки «вверх»/«вниз» (кнопки вне порядка Tab)
98
+ var handleKeyDown = (0, _react.useCallback)(function (event) {
99
+ if (!upAndDown) return;
100
+ if (event.key === 'ArrowUp') {
101
+ event.preventDefault();
102
+ if (!isUpDisabled) handleUp();
103
+ } else if (event.key === 'ArrowDown') {
104
+ event.preventDefault();
105
+ if (!isDownDisabled) handleDown();
106
+ }
107
+ }, [handleDown, handleUp, isDownDisabled, isUpDisabled, upAndDown]);
108
+
109
+ // Клик по кнопке не забирает фокус у поля: иначе срабатывал бы onBlur поля
110
+ var preventFocusLoss = (0, _react.useCallback)(function (event) {
111
+ return event.preventDefault();
112
+ }, []);
88
113
  return /*#__PURE__*/_react["default"].createElement(_InputLabel.InputLabel, {
89
114
  inFocus: inFocus,
90
115
  label: inputLabel,
91
116
  useModernStyles: useModernStyles,
92
117
  size: fieldSize,
93
- htmlFor: inputId
118
+ htmlFor: inputId,
119
+ disabled: disabled
94
120
  }, /*#__PURE__*/_react["default"].createElement(S.ControlWrapper, null, modernPlaceholder, /*#__PURE__*/_react["default"].createElement(_PureInput.PureInput, _extends({
95
121
  id: inputId,
96
122
  "aria-label": label && !inputLabel ? label : undefined,
@@ -102,18 +128,32 @@ var PositiveIntegerInput = exports.PositiveIntegerInput = function PositiveInteg
102
128
  onBlur: handleBlur,
103
129
  value: normalizeFrom(value),
104
130
  onChange: handleChange,
131
+ onKeyDown: handleKeyDown,
132
+ disabled: disabled,
133
+ inputMode: "numeric",
134
+ pattern: "[0-9]*",
105
135
  paddingRight: upAndDown ? 44 : undefined
106
136
  }, paddingAndVariantOptions, {
107
137
  useModernStyles: useModernStyles
108
138
  })), upAndDown && /*#__PURE__*/_react["default"].createElement(S.Arrows, null, /*#__PURE__*/_react["default"].createElement(S.Arrow, {
109
- onClick: handleUp,
110
- $disabled: isIncrementDisabled
139
+ type: "button",
140
+ tabIndex: -1,
141
+ "aria-label": incrementButtonAriaLabel,
142
+ "aria-controls": inputId,
143
+ disabled: isUpDisabled,
144
+ onMouseDown: preventFocusLoss,
145
+ onClick: handleUp
111
146
  }, /*#__PURE__*/_react["default"].createElement(_Arrow.Arrow, {
112
147
  isOpen: true,
113
148
  outterSize: 12
114
149
  })), /*#__PURE__*/_react["default"].createElement(S.Arrow, {
115
- onClick: handleDown,
116
- $disabled: isDecrementDisabled
150
+ type: "button",
151
+ tabIndex: -1,
152
+ "aria-label": decrementButtonAriaLabel,
153
+ "aria-controls": inputId,
154
+ disabled: isDownDisabled,
155
+ onMouseDown: preventFocusLoss,
156
+ onClick: handleDown
117
157
  }, /*#__PURE__*/_react["default"].createElement(_Arrow.Arrow, {
118
158
  isOpen: false,
119
159
  outterSize: 12
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+
3
+ var _react = _interopRequireDefault(require("react"));
4
+ var _server = require("react-dom/server");
5
+ var _ = require(".");
6
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }
7
+ function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); }
8
+ function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
9
+ function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
10
+ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
11
+ function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } }
12
+ function _arrayWithHoles(r) { if (Array.isArray(r)) return r; }
13
+ 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); }
14
+ var noop = jest.fn();
15
+ var render = function render() {
16
+ var props = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
17
+ return (0, _server.renderToStaticMarkup)(/*#__PURE__*/_react["default"].createElement(_.PositiveIntegerInput, _extends({
18
+ id: "count",
19
+ label: "\u041A\u043E\u043B-\u0432\u043E",
20
+ value: 2,
21
+ onChange: noop,
22
+ max: 5,
23
+ upAndDown: true
24
+ }, props)));
25
+ };
26
+ var buttons = function buttons(markup) {
27
+ var _markup$match;
28
+ return (_markup$match = markup.match(/<button[^>]*>/g)) !== null && _markup$match !== void 0 ? _markup$match : [];
29
+ };
30
+ var input = function input(markup) {
31
+ var _markup$match$, _markup$match2;
32
+ return (_markup$match$ = (_markup$match2 = markup.match(/<input[^>]*>/)) === null || _markup$match2 === void 0 ? void 0 : _markup$match2[0]) !== null && _markup$match$ !== void 0 ? _markup$match$ : '';
33
+ };
34
+ describe('PositiveIntegerInput: стрелки «вверх/вниз» (A11Y-16)', function () {
35
+ test('стрелки — кнопки с именами вне порядка Tab', function () {
36
+ var _buttons = buttons(render()),
37
+ _buttons2 = _slicedToArray(_buttons, 2),
38
+ up = _buttons2[0],
39
+ down = _buttons2[1];
40
+ expect(up).toContain('type="button"');
41
+ expect(up).toContain('tabindex="-1"');
42
+ expect(up).toContain('aria-label="Увеличить"');
43
+ expect(up).toContain('aria-controls="count"');
44
+ expect(up).not.toContain('disabled');
45
+ expect(down).toContain('aria-label="Уменьшить"');
46
+ expect(down).not.toContain('disabled');
47
+ });
48
+ test('имена кнопок задаются пропами', function () {
49
+ var _buttons3 = buttons(render({
50
+ incrementButtonAriaLabel: 'Увеличить количество',
51
+ decrementButtonAriaLabel: 'Уменьшить количество'
52
+ })),
53
+ _buttons4 = _slicedToArray(_buttons3, 2),
54
+ up = _buttons4[0],
55
+ down = _buttons4[1];
56
+ expect(up).toContain('aria-label="Увеличить количество"');
57
+ expect(down).toContain('aria-label="Уменьшить количество"');
58
+ });
59
+ test('на границах недоступна соответствующая кнопка', function () {
60
+ expect(buttons(render({
61
+ value: 5
62
+ }))[0]).toContain('disabled');
63
+ expect(buttons(render({
64
+ value: 0
65
+ }))[1]).toContain('disabled');
66
+ });
67
+ test('disabled блокирует поле и обе кнопки', function () {
68
+ var markup = render({
69
+ disabled: true
70
+ });
71
+ expect(input(markup)).toContain('disabled');
72
+ buttons(markup).forEach(function (button) {
73
+ return expect(button).toContain('disabled');
74
+ });
75
+ });
76
+ test('цифровая клавиатура на телефоне', function () {
77
+ var field = input(render());
78
+ expect(field).toContain('inputMode="numeric"');
79
+ expect(field).toContain('pattern="[0-9]*"');
80
+ });
81
+ });
@@ -1,6 +1,4 @@
1
1
  /// <reference types="react" />
2
2
  export declare const Arrows: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, never>> & string;
3
- export declare const Arrow: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Substitute<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {
4
- $disabled: boolean;
5
- }>> & string;
3
+ export declare const Arrow: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, never>> & string;
6
4
  export declare const ControlWrapper: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, never>> & string;
@@ -1,24 +1,22 @@
1
1
  "use strict";
2
2
 
3
- function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
4
3
  Object.defineProperty(exports, "__esModule", {
5
4
  value: true
6
5
  });
7
6
  exports.ControlWrapper = exports.Arrows = exports.Arrow = void 0;
8
- var _styledComponents = _interopRequireWildcard(require("styled-components"));
9
- function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { return e ? t : r; })(e); }
10
- function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != _typeof(e) && "function" != typeof e) return { "default": e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n["default"] = e, t && t.set(e, n), n; }
7
+ var _styledComponents = _interopRequireDefault(require("styled-components"));
8
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }
11
9
  var Arrows = exports.Arrows = _styledComponents["default"].div.withConfig({
12
10
  displayName: "styles__Arrows",
13
11
  componentId: "sc-ctutr8-0"
14
- })(["display:flex;flex-direction:column;position:absolute;height:100%;justify-content:center;top:0;right:18px;"]);
15
- var Arrow = exports.Arrow = _styledComponents["default"].div.withConfig({
12
+ })(["display:flex;flex-direction:column;position:absolute;height:100%;justify-content:center;top:0;right:12px;"]);
13
+
14
+ // Кнопка 24×24 (WCAG 2.5.8) со стрелкой 12 px по центру; в низком поле кнопки сжимаются по высоте,
15
+ // чтобы не вылезать за его границы
16
+ var Arrow = exports.Arrow = _styledComponents["default"].button.withConfig({
16
17
  displayName: "styles__Arrow",
17
18
  componentId: "sc-ctutr8-1"
18
- })(["cursor:pointer;display:flex;user-select:none;", ""], function (_ref) {
19
- var $disabled = _ref.$disabled;
20
- return $disabled && (0, _styledComponents.css)(["opacity:0.4;pointer-events:none;"]);
21
- });
19
+ })(["flex:0 1 24px;min-height:0;width:24px;display:flex;align-items:center;justify-content:center;padding:0;border:0;background:transparent;color:inherit;cursor:pointer;user-select:none;&:disabled{opacity:0.4;cursor:default;}"]);
22
20
  var ControlWrapper = exports.ControlWrapper = _styledComponents["default"].div.withConfig({
23
21
  displayName: "styles__ControlWrapper",
24
22
  componentId: "sc-ctutr8-2"
@@ -158,13 +158,20 @@ var SelectInner = function SelectInner(props) {
158
158
  }, [activeIndex, baseId]);
159
159
  var dropdownRef = (0, _react.useRef)(null);
160
160
  var wrapperRef = (0, _useOutsideClick.useOutsideClick)(handleHide, [dropdownRef]);
161
- var handleFocus = (0, _react.useCallback)(function (event) {
162
- handleShow();
163
- focusHandler(event);
164
- }, [focusHandler, handleShow]);
161
+
162
+ // Список не открывается на фокус (WCAG 2.4.11): иначе при переходе по Tab он перекрывает
163
+ // следующие элементы. Открытие — кликом, Enter, пробелом или стрелками
164
+ var handleFocus = focusHandler;
165
165
  var handleBlur = (0, _react.useCallback)(function (event) {
166
+ var _wrapperRef$current, _dropdownRef$current;
166
167
  var relatedTarget = event.relatedTarget;
167
168
  var isOptionClick = (relatedTarget === null || relatedTarget === void 0 ? void 0 : relatedTarget.dataset.optionIdentifier) === OPTION_IDENTIFIER;
169
+
170
+ // Фокус ушёл на другой элемент вне поля и списка — закрываем список. Без relatedTarget
171
+ // (клик по неактивной области списка, уход из окна) не закрываем: клик снаружи
172
+ // обрабатывает useOutsideClick
173
+ var isFocusOutside = !!relatedTarget && !((_wrapperRef$current = wrapperRef.current) !== null && _wrapperRef$current !== void 0 && _wrapperRef$current.contains(relatedTarget)) && !((_dropdownRef$current = dropdownRef.current) !== null && _dropdownRef$current !== void 0 && _dropdownRef$current.contains(relatedTarget));
174
+ if (isFocusOutside) handleHide();
168
175
  if (!isOptionClick) {
169
176
  blurHandler(event);
170
177
  return;
@@ -172,7 +179,7 @@ var SelectInner = function SelectInner(props) {
172
179
  delayBlur.current = function () {
173
180
  return blurHandler(event);
174
181
  };
175
- }, [blurHandler]);
182
+ }, [blurHandler, handleHide, wrapperRef]);
176
183
  var selectValue = (0, _react.useCallback)(function (newValue) {
177
184
  if (isMultiple) {
178
185
  var next = multipleValue.includes(newValue) ? multipleValue.filter(function (item) {
@@ -224,6 +231,10 @@ var SelectInner = function SelectInner(props) {
224
231
  handleHide();
225
232
  }
226
233
  break;
234
+ case 'Tab':
235
+ // фокус уходит дальше по странице — список не должен оставаться поверх
236
+ if (isOpen) handleHide();
237
+ break;
227
238
  default:
228
239
  break;
229
240
  }
@@ -241,6 +252,12 @@ var SelectInner = function SelectInner(props) {
241
252
  (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 || _inputRef$current.blur();
242
253
  handleShow();
243
254
  };
255
+
256
+ // Раньше список открывался фокусом, который ставит и клик; теперь клик открывает его сам.
257
+ // Повторный клик по полю закрывает список, как у нативного select
258
+ var handleToggleClick = function handleToggleClick() {
259
+ if (isOpen) handleHide();else handleShow();
260
+ };
244
261
  var handleOptionRender = function handleOptionRender(option) {
245
262
  if (renderOption) {
246
263
  if (!option.entity) {
@@ -367,7 +384,7 @@ var SelectInner = function SelectInner(props) {
367
384
  isOpen: isOpen,
368
385
  onReset: isResetButtonEnabled ? handleReset : undefined,
369
386
  size: fieldSize,
370
- onClick: isDrawerOptions || !!renderedValue ? handleInputClick : undefined,
387
+ onClick: isDrawerOptions || !!renderedValue ? handleInputClick : handleToggleClick,
371
388
  required: required,
372
389
  useModernStyles: useModernStyles,
373
390
  comboboxProps: {
@@ -18,7 +18,9 @@ export var CountryItem = function CountryItem(_ref) {
18
18
  return /*#__PURE__*/React.createElement(S.Wrapper, {
19
19
  $palette: palette,
20
20
  onClick: handleSelect,
21
- type: "button"
21
+ type: "button",
22
+ role: "option",
23
+ "aria-selected": isSelected
22
24
  }, /*#__PURE__*/React.createElement(S.Title, {
23
25
  $isSelected: isSelected,
24
26
  $palette: palette
@@ -1,4 +1,4 @@
1
- var _excluded = ["value", "onChange", "options", "placeholder", "disabled", "selectedLabel"];
1
+ var _excluded = ["value", "onChange", "options", "placeholder", "disabled", "selectedLabel", "buttonAriaLabelPrefix"];
2
2
  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); }
3
3
  function _toConsumableArray(r) { return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread(); }
4
4
  function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
@@ -12,7 +12,7 @@ function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" !=
12
12
  function _arrayWithHoles(r) { if (Array.isArray(r)) return r; }
13
13
  function _objectWithoutProperties(e, t) { if (null == e) return {}; var o, r, i = _objectWithoutPropertiesLoose(e, t); if (Object.getOwnPropertySymbols) { var s = Object.getOwnPropertySymbols(e); for (r = 0; r < s.length; r++) o = s[r], t.includes(o) || {}.propertyIsEnumerable.call(e, o) && (i[o] = e[o]); } return i; }
14
14
  function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t = {}; for (var n in r) if ({}.hasOwnProperty.call(r, n)) { if (e.includes(n)) continue; t[n] = r[n]; } return t; }
15
- import React, { useCallback, useMemo, useRef, useState } from 'react';
15
+ import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
16
16
  import * as S from './stlyes';
17
17
  import { CountryItem } from './components/CountryItem';
18
18
  import { SearchInput } from '../SearchInput';
@@ -31,7 +31,10 @@ export var CountrySelect = function CountrySelect(_ref) {
31
31
  _ref$disabled = _ref.disabled,
32
32
  disabled = _ref$disabled === void 0 ? false : _ref$disabled,
33
33
  selectedLabel = _ref.selectedLabel,
34
+ _ref$buttonAriaLabelP = _ref.buttonAriaLabelPrefix,
35
+ buttonAriaLabelPrefix = _ref$buttonAriaLabelP === void 0 ? 'Код страны' : _ref$buttonAriaLabelP,
34
36
  dropdownPositionProps = _objectWithoutProperties(_ref, _excluded);
37
+ var listboxId = useId();
35
38
  var palette = useComponentPalette('countrySelect');
36
39
  var _useState = useState(false),
37
40
  _useState2 = _slicedToArray(_useState, 2),
@@ -78,8 +81,12 @@ export var CountrySelect = function CountrySelect(_ref) {
78
81
  }, /*#__PURE__*/React.createElement(S.HeaderWrapper, {
79
82
  type: "button",
80
83
  onClick: handleDropdownTrigger,
81
- disabled: disabled
82
- }, selectedOption.flag && /*#__PURE__*/React.createElement(Flag, {
84
+ disabled: disabled,
85
+ "aria-label": selectedOption ? "".concat(buttonAriaLabelPrefix, ": ").concat(selectedOption.label) : buttonAriaLabelPrefix,
86
+ "aria-haspopup": "listbox",
87
+ "aria-expanded": isOpen,
88
+ "aria-controls": isOpen ? listboxId : undefined
89
+ }, (selectedOption === null || selectedOption === void 0 ? void 0 : selectedOption.flag) && /*#__PURE__*/React.createElement(Flag, {
83
90
  isSmall: false,
84
91
  flag: selectedOption.flag
85
92
  }), !disabled && /*#__PURE__*/React.createElement(Arrow, {
@@ -99,6 +106,10 @@ export var CountrySelect = function CountrySelect(_ref) {
99
106
  value: search
100
107
  })), /*#__PURE__*/React.createElement(Scrollbar, {
101
108
  maxHeight: 192
109
+ }, /*#__PURE__*/React.createElement("div", {
110
+ role: "listbox",
111
+ id: listboxId,
112
+ "aria-label": buttonAriaLabelPrefix
102
113
  }, filtredOptions.map(function (option) {
103
114
  return /*#__PURE__*/React.createElement(CountryItem, {
104
115
  key: option.value,
@@ -112,5 +123,5 @@ export var CountrySelect = function CountrySelect(_ref) {
112
123
  hint: option.hint,
113
124
  flag: option.flag
114
125
  });
115
- })))));
126
+ }))))));
116
127
  };
@@ -0,0 +1,82 @@
1
+ import React from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { CountrySelect } from '.';
4
+ import { PhoneInput } from '../PhoneInput';
5
+ var options = [{
6
+ label: 'Россия',
7
+ value: 'RUS',
8
+ description: '+7',
9
+ flag: 'RUS'
10
+ }, {
11
+ label: 'Казахстан',
12
+ value: 'KAZ',
13
+ description: '+7',
14
+ flag: 'KAZ'
15
+ }];
16
+
17
+ // Серверный рендер: предупреждения про useLayoutEffect (Dropdown) ожидаемы и к проверке не относятся
18
+ // eslint-disable-next-line no-console
19
+ var originalError = console.error;
20
+ beforeAll(function () {
21
+ jest.spyOn(console, 'error').mockImplementation(function (message) {
22
+ if (typeof message === 'string' && message.includes('useLayoutEffect does nothing on the server')) return;
23
+ // eslint-disable-next-line no-console
24
+ for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
25
+ rest[_key - 1] = arguments[_key];
26
+ }
27
+ originalError.apply(void 0, [message].concat(rest));
28
+ });
29
+ });
30
+ afterAll(function () {
31
+ return jest.restoreAllMocks();
32
+ });
33
+ var noop = jest.fn();
34
+ var renderButton = function renderButton(markup) {
35
+ var match = markup.match(/<button[^>]*aria-haspopup[^>]*>/);
36
+ if (!match) throw new Error("\u041A\u043D\u043E\u043F\u043A\u0430 \u0432\u044B\u0431\u043E\u0440\u0430 \u0441\u0442\u0440\u0430\u043D\u044B \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430: ".concat(markup));
37
+ return match[0];
38
+ };
39
+ describe('CountrySelect: доступное имя и состояние кнопки (A11Y-12)', function () {
40
+ test('имя по умолчанию — «Код страны: <страна>», свёрнут, раскрывает listbox', function () {
41
+ var button = renderButton(renderToStaticMarkup(/*#__PURE__*/React.createElement(CountrySelect, {
42
+ value: "KAZ",
43
+ onChange: noop,
44
+ options: options,
45
+ selectedLabel: ""
46
+ })));
47
+ expect(button).toContain('aria-label="Код страны: Казахстан"');
48
+ expect(button).toContain('aria-haspopup="listbox"');
49
+ expect(button).toContain('aria-expanded="false"');
50
+ expect(button).not.toContain('aria-controls');
51
+ });
52
+ test('префикс имени задаётся пропом buttonAriaLabelPrefix', function () {
53
+ var button = renderButton(renderToStaticMarkup(/*#__PURE__*/React.createElement(CountrySelect, {
54
+ value: "RUS",
55
+ onChange: noop,
56
+ options: options,
57
+ selectedLabel: "",
58
+ buttonAriaLabelPrefix: "Country code"
59
+ })));
60
+ expect(button).toContain('aria-label="Country code: Россия"');
61
+ });
62
+ test('PhoneInput пробрасывает buttonAriaLabelPrefix из countrySelectProps', function () {
63
+ var button = renderButton(renderToStaticMarkup(/*#__PURE__*/React.createElement(PhoneInput, {
64
+ label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D",
65
+ value: "",
66
+ onChange: noop,
67
+ countries: [{
68
+ label: 'Russia',
69
+ value: 'RUS'
70
+ }, {
71
+ label: 'Belarus',
72
+ value: 'BLR'
73
+ }],
74
+ countrySelectProps: {
75
+ selectedLabel: '',
76
+ placeholder: '',
77
+ buttonAriaLabelPrefix: 'Country code'
78
+ }
79
+ })));
80
+ expect(button).toContain('aria-label="Country code: Russia"');
81
+ });
82
+ });
@@ -37,12 +37,17 @@ export var DateInput = function DateInput(_ref) {
37
37
  useModernStyles = _ref$useModernStyles === void 0 ? false : _ref$useModernStyles,
38
38
  _ref$required = _ref.required,
39
39
  required = _ref$required === void 0 ? false : _ref$required,
40
- id = _ref.id;
40
+ id = _ref.id,
41
+ inputMode = _ref.inputMode;
41
42
  var palette = useComponentPalette('dateInput');
42
43
  var inputRef = useRef(null);
43
44
  var fallbackId = useId();
44
45
  var inputId = id !== null && id !== void 0 ? id : fallbackId;
45
46
  var isMobile = useMobile();
47
+ // На мобильном тап по полю открывает календарь в шторке — экранная клавиатура поверх него
48
+ // не нужна (MOB-8). Ввод с клавиатуры остаётся (физическая клавиатура, вставка); проп
49
+ // inputMode переопределяет значение, например 'none' для всех тач-экранов
50
+ var fieldInputMode = inputMode !== null && inputMode !== void 0 ? inputMode : isMobile ? 'none' : undefined;
46
51
  var _useState = useState(false),
47
52
  _useState2 = _slicedToArray(_useState, 2),
48
53
  isOpen = _useState2[0],
@@ -133,6 +138,7 @@ export var DateInput = function DateInput(_ref) {
133
138
  onBlur: handleBlur,
134
139
  value: stringValue,
135
140
  onChange: handleStringValueChange,
141
+ inputMode: fieldInputMode,
136
142
  paddingRight: 44,
137
143
  useModernStyles: useModernStyles
138
144
  }, paddingAndVariantOptions)), /*#__PURE__*/React.createElement(S.Icon, {
@@ -0,0 +1,69 @@
1
+ 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); }
2
+ import React from 'react';
3
+ import { renderToStaticMarkup } from 'react-dom/server';
4
+ import { DateInput } from '.';
5
+
6
+ // Серверный рендер: предупреждения про useLayoutEffect (Dropdown) ожидаемы и к проверке не относятся
7
+ // eslint-disable-next-line no-console
8
+ var originalError = console.error;
9
+ beforeAll(function () {
10
+ jest.spyOn(console, 'error').mockImplementation(function (message) {
11
+ if (typeof message === 'string' && message.includes('useLayoutEffect does nothing on the server')) return;
12
+ // eslint-disable-next-line no-console
13
+ for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
14
+ rest[_key - 1] = arguments[_key];
15
+ }
16
+ originalError.apply(void 0, [message].concat(rest));
17
+ });
18
+ });
19
+ afterAll(function () {
20
+ return jest.restoreAllMocks();
21
+ });
22
+ var noop = jest.fn();
23
+
24
+ // useMobile читает window.matchMedia при первом рендере
25
+ var withViewport = function withViewport(isMobile, render) {
26
+ var globalWithWindow = globalThis;
27
+ var previous = globalWithWindow.window;
28
+ globalWithWindow.window = {
29
+ matchMedia: function matchMedia() {
30
+ return {
31
+ matches: isMobile
32
+ };
33
+ }
34
+ };
35
+ try {
36
+ return render();
37
+ } finally {
38
+ globalWithWindow.window = previous;
39
+ }
40
+ };
41
+ var renderInput = function renderInput(isMobile) {
42
+ var _markup$match$, _markup$match;
43
+ var props = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
44
+ var markup = withViewport(isMobile, function () {
45
+ return renderToStaticMarkup(/*#__PURE__*/React.createElement(DateInput, _extends({
46
+ id: "date",
47
+ label: "\u0414\u0430\u0442\u0430",
48
+ value: null,
49
+ onChange: noop
50
+ }, props)));
51
+ });
52
+ return (_markup$match$ = (_markup$match = markup.match(/<input[^>]*>/)) === null || _markup$match === void 0 ? void 0 : _markup$match[0]) !== null && _markup$match$ !== void 0 ? _markup$match$ : '';
53
+ };
54
+ describe('DateInput: экранная клавиатура (MOB-8)', function () {
55
+ test('на мобильном — inputmode="none": тап открывает только календарь', function () {
56
+ expect(renderInput(true)).toContain('inputMode="none"');
57
+ });
58
+ test('на десктопе inputmode не задан', function () {
59
+ expect(renderInput(false)).not.toContain('inputMode');
60
+ });
61
+ test('проп inputMode переопределяет значение по умолчанию', function () {
62
+ expect(renderInput(false, {
63
+ inputMode: 'none'
64
+ })).toContain('inputMode="none"');
65
+ expect(renderInput(true, {
66
+ inputMode: 'numeric'
67
+ })).toContain('inputMode="numeric"');
68
+ });
69
+ });
@@ -202,7 +202,8 @@ export var PhoneInput = function PhoneInput(_ref) {
202
202
  positionRight: "0",
203
203
  positionLeft: "-20px",
204
204
  disabled: disabled,
205
- selectedLabel: (countrySelectProps === null || countrySelectProps === void 0 ? void 0 : countrySelectProps.selectedLabel) || ''
205
+ selectedLabel: (countrySelectProps === null || countrySelectProps === void 0 ? void 0 : countrySelectProps.selectedLabel) || '',
206
+ buttonAriaLabelPrefix: countrySelectProps === null || countrySelectProps === void 0 ? void 0 : countrySelectProps.buttonAriaLabelPrefix
206
207
  }))), /*#__PURE__*/React.createElement(InputMask, {
207
208
  value: inputValue,
208
209
  onFocus: focusHandler,
@@ -26,7 +26,13 @@ export var PositiveIntegerInput = function PositiveIntegerInput(_ref) {
26
26
  size = _ref.size,
27
27
  _ref$useModernStyles = _ref.useModernStyles,
28
28
  useModernStyles = _ref$useModernStyles === void 0 ? false : _ref$useModernStyles,
29
- id = _ref.id;
29
+ id = _ref.id,
30
+ _ref$disabled = _ref.disabled,
31
+ disabled = _ref$disabled === void 0 ? false : _ref$disabled,
32
+ _ref$incrementButtonA = _ref.incrementButtonAriaLabel,
33
+ incrementButtonAriaLabel = _ref$incrementButtonA === void 0 ? 'Увеличить' : _ref$incrementButtonA,
34
+ _ref$decrementButtonA = _ref.decrementButtonAriaLabel,
35
+ decrementButtonAriaLabel = _ref$decrementButtonA === void 0 ? 'Уменьшить' : _ref$decrementButtonA;
30
36
  var _useInputFocus = useInputFocus({
31
37
  onFocus: onFocus,
32
38
  onBlur: onBlur
@@ -76,12 +82,32 @@ export var PositiveIntegerInput = function PositiveIntegerInput(_ref) {
76
82
  var handleDown = useCallback(function () {
77
83
  onChange(decrement(value));
78
84
  }, [decrement, value, onChange]);
85
+ var isUpDisabled = disabled || isIncrementDisabled;
86
+ var isDownDisabled = disabled || isDecrementDisabled;
87
+
88
+ // Стрелки клавиатуры в поле — как кнопки «вверх»/«вниз» (кнопки вне порядка Tab)
89
+ var handleKeyDown = useCallback(function (event) {
90
+ if (!upAndDown) return;
91
+ if (event.key === 'ArrowUp') {
92
+ event.preventDefault();
93
+ if (!isUpDisabled) handleUp();
94
+ } else if (event.key === 'ArrowDown') {
95
+ event.preventDefault();
96
+ if (!isDownDisabled) handleDown();
97
+ }
98
+ }, [handleDown, handleUp, isDownDisabled, isUpDisabled, upAndDown]);
99
+
100
+ // Клик по кнопке не забирает фокус у поля: иначе срабатывал бы onBlur поля
101
+ var preventFocusLoss = useCallback(function (event) {
102
+ return event.preventDefault();
103
+ }, []);
79
104
  return /*#__PURE__*/React.createElement(InputLabel, {
80
105
  inFocus: inFocus,
81
106
  label: inputLabel,
82
107
  useModernStyles: useModernStyles,
83
108
  size: fieldSize,
84
- htmlFor: inputId
109
+ htmlFor: inputId,
110
+ disabled: disabled
85
111
  }, /*#__PURE__*/React.createElement(S.ControlWrapper, null, modernPlaceholder, /*#__PURE__*/React.createElement(PureInput, _extends({
86
112
  id: inputId,
87
113
  "aria-label": label && !inputLabel ? label : undefined,
@@ -93,18 +119,32 @@ export var PositiveIntegerInput = function PositiveIntegerInput(_ref) {
93
119
  onBlur: handleBlur,
94
120
  value: normalizeFrom(value),
95
121
  onChange: handleChange,
122
+ onKeyDown: handleKeyDown,
123
+ disabled: disabled,
124
+ inputMode: "numeric",
125
+ pattern: "[0-9]*",
96
126
  paddingRight: upAndDown ? 44 : undefined
97
127
  }, paddingAndVariantOptions, {
98
128
  useModernStyles: useModernStyles
99
129
  })), upAndDown && /*#__PURE__*/React.createElement(S.Arrows, null, /*#__PURE__*/React.createElement(S.Arrow, {
100
- onClick: handleUp,
101
- $disabled: isIncrementDisabled
130
+ type: "button",
131
+ tabIndex: -1,
132
+ "aria-label": incrementButtonAriaLabel,
133
+ "aria-controls": inputId,
134
+ disabled: isUpDisabled,
135
+ onMouseDown: preventFocusLoss,
136
+ onClick: handleUp
102
137
  }, /*#__PURE__*/React.createElement(Arrow, {
103
138
  isOpen: true,
104
139
  outterSize: 12
105
140
  })), /*#__PURE__*/React.createElement(S.Arrow, {
106
- onClick: handleDown,
107
- $disabled: isDecrementDisabled
141
+ type: "button",
142
+ tabIndex: -1,
143
+ "aria-label": decrementButtonAriaLabel,
144
+ "aria-controls": inputId,
145
+ disabled: isDownDisabled,
146
+ onMouseDown: preventFocusLoss,
147
+ onClick: handleDown
108
148
  }, /*#__PURE__*/React.createElement(Arrow, {
109
149
  isOpen: false,
110
150
  outterSize: 12
@@ -0,0 +1,78 @@
1
+ function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); }
2
+ function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
3
+ function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
4
+ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
5
+ function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } }
6
+ function _arrayWithHoles(r) { if (Array.isArray(r)) return r; }
7
+ 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); }
8
+ import React from 'react';
9
+ import { renderToStaticMarkup } from 'react-dom/server';
10
+ import { PositiveIntegerInput } from '.';
11
+ var noop = jest.fn();
12
+ var render = function render() {
13
+ var props = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
14
+ return renderToStaticMarkup(/*#__PURE__*/React.createElement(PositiveIntegerInput, _extends({
15
+ id: "count",
16
+ label: "\u041A\u043E\u043B-\u0432\u043E",
17
+ value: 2,
18
+ onChange: noop,
19
+ max: 5,
20
+ upAndDown: true
21
+ }, props)));
22
+ };
23
+ var buttons = function buttons(markup) {
24
+ var _markup$match;
25
+ return (_markup$match = markup.match(/<button[^>]*>/g)) !== null && _markup$match !== void 0 ? _markup$match : [];
26
+ };
27
+ var input = function input(markup) {
28
+ var _markup$match$, _markup$match2;
29
+ return (_markup$match$ = (_markup$match2 = markup.match(/<input[^>]*>/)) === null || _markup$match2 === void 0 ? void 0 : _markup$match2[0]) !== null && _markup$match$ !== void 0 ? _markup$match$ : '';
30
+ };
31
+ describe('PositiveIntegerInput: стрелки «вверх/вниз» (A11Y-16)', function () {
32
+ test('стрелки — кнопки с именами вне порядка Tab', function () {
33
+ var _buttons = buttons(render()),
34
+ _buttons2 = _slicedToArray(_buttons, 2),
35
+ up = _buttons2[0],
36
+ down = _buttons2[1];
37
+ expect(up).toContain('type="button"');
38
+ expect(up).toContain('tabindex="-1"');
39
+ expect(up).toContain('aria-label="Увеличить"');
40
+ expect(up).toContain('aria-controls="count"');
41
+ expect(up).not.toContain('disabled');
42
+ expect(down).toContain('aria-label="Уменьшить"');
43
+ expect(down).not.toContain('disabled');
44
+ });
45
+ test('имена кнопок задаются пропами', function () {
46
+ var _buttons3 = buttons(render({
47
+ incrementButtonAriaLabel: 'Увеличить количество',
48
+ decrementButtonAriaLabel: 'Уменьшить количество'
49
+ })),
50
+ _buttons4 = _slicedToArray(_buttons3, 2),
51
+ up = _buttons4[0],
52
+ down = _buttons4[1];
53
+ expect(up).toContain('aria-label="Увеличить количество"');
54
+ expect(down).toContain('aria-label="Уменьшить количество"');
55
+ });
56
+ test('на границах недоступна соответствующая кнопка', function () {
57
+ expect(buttons(render({
58
+ value: 5
59
+ }))[0]).toContain('disabled');
60
+ expect(buttons(render({
61
+ value: 0
62
+ }))[1]).toContain('disabled');
63
+ });
64
+ test('disabled блокирует поле и обе кнопки', function () {
65
+ var markup = render({
66
+ disabled: true
67
+ });
68
+ expect(input(markup)).toContain('disabled');
69
+ buttons(markup).forEach(function (button) {
70
+ return expect(button).toContain('disabled');
71
+ });
72
+ });
73
+ test('цифровая клавиатура на телефоне', function () {
74
+ var field = input(render());
75
+ expect(field).toContain('inputMode="numeric"');
76
+ expect(field).toContain('pattern="[0-9]*"');
77
+ });
78
+ });
@@ -1,15 +1,15 @@
1
- import styled, { css } from 'styled-components';
1
+ import styled from 'styled-components';
2
2
  export var Arrows = styled.div.withConfig({
3
3
  displayName: "styles__Arrows",
4
4
  componentId: "sc-ctutr8-0"
5
- })(["display:flex;flex-direction:column;position:absolute;height:100%;justify-content:center;top:0;right:18px;"]);
6
- export var Arrow = styled.div.withConfig({
5
+ })(["display:flex;flex-direction:column;position:absolute;height:100%;justify-content:center;top:0;right:12px;"]);
6
+
7
+ // Кнопка 24×24 (WCAG 2.5.8) со стрелкой 12 px по центру; в низком поле кнопки сжимаются по высоте,
8
+ // чтобы не вылезать за его границы
9
+ export var Arrow = styled.button.withConfig({
7
10
  displayName: "styles__Arrow",
8
11
  componentId: "sc-ctutr8-1"
9
- })(["cursor:pointer;display:flex;user-select:none;", ""], function (_ref) {
10
- var $disabled = _ref.$disabled;
11
- return $disabled && css(["opacity:0.4;pointer-events:none;"]);
12
- });
12
+ })(["flex:0 1 24px;min-height:0;width:24px;display:flex;align-items:center;justify-content:center;padding:0;border:0;background:transparent;color:inherit;cursor:pointer;user-select:none;&:disabled{opacity:0.4;cursor:default;}"]);
13
13
  export var ControlWrapper = styled.div.withConfig({
14
14
  displayName: "styles__ControlWrapper",
15
15
  componentId: "sc-ctutr8-2"
@@ -148,13 +148,20 @@ var SelectInner = function SelectInner(props) {
148
148
  }, [activeIndex, baseId]);
149
149
  var dropdownRef = useRef(null);
150
150
  var wrapperRef = useOutsideClick(handleHide, [dropdownRef]);
151
- var handleFocus = useCallback(function (event) {
152
- handleShow();
153
- focusHandler(event);
154
- }, [focusHandler, handleShow]);
151
+
152
+ // Список не открывается на фокус (WCAG 2.4.11): иначе при переходе по Tab он перекрывает
153
+ // следующие элементы. Открытие — кликом, Enter, пробелом или стрелками
154
+ var handleFocus = focusHandler;
155
155
  var handleBlur = useCallback(function (event) {
156
+ var _wrapperRef$current, _dropdownRef$current;
156
157
  var relatedTarget = event.relatedTarget;
157
158
  var isOptionClick = (relatedTarget === null || relatedTarget === void 0 ? void 0 : relatedTarget.dataset.optionIdentifier) === OPTION_IDENTIFIER;
159
+
160
+ // Фокус ушёл на другой элемент вне поля и списка — закрываем список. Без relatedTarget
161
+ // (клик по неактивной области списка, уход из окна) не закрываем: клик снаружи
162
+ // обрабатывает useOutsideClick
163
+ var isFocusOutside = !!relatedTarget && !((_wrapperRef$current = wrapperRef.current) !== null && _wrapperRef$current !== void 0 && _wrapperRef$current.contains(relatedTarget)) && !((_dropdownRef$current = dropdownRef.current) !== null && _dropdownRef$current !== void 0 && _dropdownRef$current.contains(relatedTarget));
164
+ if (isFocusOutside) handleHide();
158
165
  if (!isOptionClick) {
159
166
  blurHandler(event);
160
167
  return;
@@ -162,7 +169,7 @@ var SelectInner = function SelectInner(props) {
162
169
  delayBlur.current = function () {
163
170
  return blurHandler(event);
164
171
  };
165
- }, [blurHandler]);
172
+ }, [blurHandler, handleHide, wrapperRef]);
166
173
  var selectValue = useCallback(function (newValue) {
167
174
  if (isMultiple) {
168
175
  var next = multipleValue.includes(newValue) ? multipleValue.filter(function (item) {
@@ -214,6 +221,10 @@ var SelectInner = function SelectInner(props) {
214
221
  handleHide();
215
222
  }
216
223
  break;
224
+ case 'Tab':
225
+ // фокус уходит дальше по странице — список не должен оставаться поверх
226
+ if (isOpen) handleHide();
227
+ break;
217
228
  default:
218
229
  break;
219
230
  }
@@ -231,6 +242,12 @@ var SelectInner = function SelectInner(props) {
231
242
  (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 || _inputRef$current.blur();
232
243
  handleShow();
233
244
  };
245
+
246
+ // Раньше список открывался фокусом, который ставит и клик; теперь клик открывает его сам.
247
+ // Повторный клик по полю закрывает список, как у нативного select
248
+ var handleToggleClick = function handleToggleClick() {
249
+ if (isOpen) handleHide();else handleShow();
250
+ };
234
251
  var handleOptionRender = function handleOptionRender(option) {
235
252
  if (renderOption) {
236
253
  if (!option.entity) {
@@ -357,7 +374,7 @@ var SelectInner = function SelectInner(props) {
357
374
  isOpen: isOpen,
358
375
  onReset: isResetButtonEnabled ? handleReset : undefined,
359
376
  size: fieldSize,
360
- onClick: isDrawerOptions || !!renderedValue ? handleInputClick : undefined,
377
+ onClick: isDrawerOptions || !!renderedValue ? handleInputClick : handleToggleClick,
361
378
  required: required,
362
379
  useModernStyles: useModernStyles,
363
380
  comboboxProps: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invoicebox/ui",
3
- "version": "0.1.0-alpha.322",
3
+ "version": "0.1.0-alpha.325",
4
4
  "description": "Invoicebox UI",
5
5
  "license": "Apache License 2.0",
6
6
  "scripts": {