@ecohouse/ui 0.1.60 → 0.1.62

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 (44) hide show
  1. package/dist/index.cjs +345 -115
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +16 -4
  4. package/dist/index.d.ts +16 -4
  5. package/dist/index.js +344 -117
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/Checkbox/Checkbox.tsx +6 -0
  9. package/src/components/Counter/Counter.test.ts +38 -0
  10. package/src/components/Counter/Counter.tsx +7 -10
  11. package/src/components/Counter/Counter.utils.ts +28 -0
  12. package/src/components/DatePicker/DatePicker.tsx +5 -14
  13. package/src/components/Drawer/Drawer.tsx +15 -12
  14. package/src/components/Modal/Modal.tsx +31 -11
  15. package/src/components/RadioButton/RadioButton.tsx +14 -0
  16. package/src/components/Select/Select.tsx +16 -8
  17. package/src/components/Toggle/Toggle.tsx +6 -0
  18. package/src/components/VerificationCodeInput/VerificationCodeInput.test.ts +60 -12
  19. package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +29 -24
  20. package/src/components/VerificationCodeInput/VerificationCodeInput.utils.ts +53 -13
  21. package/src/icons/PaymentCard/PaymentCardIcon.tsx +14 -0
  22. package/src/icons/PaymentCard/PaymentCardIcon.web.tsx +13 -0
  23. package/src/icons/PaymentCard/index.ts +1 -0
  24. package/src/icons/PaymentCard/paymentCardPath.ts +3 -0
  25. package/src/icons/Receipt/ReceiptIcon.tsx +14 -0
  26. package/src/icons/Receipt/ReceiptIcon.web.tsx +13 -0
  27. package/src/icons/Receipt/index.ts +1 -0
  28. package/src/icons/Receipt/receiptPath.ts +3 -0
  29. package/src/icons/index.ts +2 -0
  30. package/src/icons/registry.ts +4 -0
  31. package/src/index.ts +8 -1
  32. package/src/theme/typography.ts +2 -2
  33. package/src/utils/dateUtils.test.ts +72 -0
  34. package/src/utils/dateUtils.ts +30 -2
  35. package/src/utils/focusTrap.test.ts +26 -0
  36. package/src/utils/focusTrap.ts +39 -0
  37. package/src/utils/overlayStack.test.ts +175 -0
  38. package/src/utils/overlayStack.ts +105 -0
  39. package/src/utils/useHydrated.test.ts +14 -0
  40. package/src/utils/useHydrated.ts +14 -0
  41. package/src/utils/useOverlayLayer.ts +41 -0
  42. package/src/utils/useWebFocusTrap.ts +68 -0
  43. package/src/utils/webToggleKey.test.ts +49 -0
  44. package/src/utils/webToggleKey.ts +23 -0
@@ -22,7 +22,13 @@ import {
22
22
  } from "react-native";
23
23
  import { colors, fonts } from "../../theme";
24
24
  import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
25
- import { normalizeVerificationCode, updateVerificationCode } from "./VerificationCodeInput.utils";
25
+ import {
26
+ isVerificationCodeComplete,
27
+ reconcileVerificationCells,
28
+ toVerificationCells,
29
+ updateVerificationCells,
30
+ verificationCellsToCode,
31
+ } from "./VerificationCodeInput.utils";
26
32
 
27
33
  const DEFAULT_LENGTH = 6;
28
34
  const DESKTOP_CELL_WIDTH = 54.333335876464844;
@@ -85,17 +91,18 @@ export const VerificationCodeInput = forwardRef<
85
91
  ) {
86
92
  const safeLength = Math.max(1, Math.floor(length));
87
93
  const isControlled = typeof value === "string";
88
- const [uncontrolledValue, setUncontrolledValue] = useState(() =>
89
- normalizeVerificationCode(defaultValue, safeLength),
90
- );
94
+ // Fixed-length cells, so clearing a middle cell leaves a gap instead of shifting digits.
95
+ const [cellsState, setCellsState] = useState(() => toVerificationCells(defaultValue, safeLength));
91
96
  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
92
97
  const { width: viewportWidth } = useWindowDimensions();
93
98
  const rootRef = useRef<ComponentRef<typeof View>>(null);
94
99
  const inputRefs = useRef<Array<ComponentRef<typeof TextInput> | null>>([]);
95
- const currentCode = normalizeVerificationCode(
96
- isControlled ? value : uncontrolledValue,
100
+ const cells = reconcileVerificationCells(
101
+ cellsState,
102
+ isControlled ? value : verificationCellsToCode(cellsState),
97
103
  safeLength,
98
104
  );
105
+ const currentCode = verificationCellsToCode(cells);
99
106
 
100
107
  useApplyWebClassName(rootRef, className);
101
108
 
@@ -108,37 +115,35 @@ export const VerificationCodeInput = forwardRef<
108
115
  );
109
116
 
110
117
  const commit = useCallback(
111
- (nextValue: string) => {
112
- const normalized = normalizeVerificationCode(nextValue, safeLength);
113
-
114
- if (!isControlled) {
115
- setUncontrolledValue(normalized);
116
- }
118
+ (nextCells: string[]) => {
119
+ // Kept in controlled mode too, so gaps survive while the parent echoes the value back.
120
+ setCellsState(nextCells);
121
+ const code = verificationCellsToCode(nextCells);
117
122
 
118
- onValueChange?.(normalized);
123
+ onValueChange?.(code);
119
124
 
120
- if (normalized.length === safeLength) {
121
- onComplete?.(normalized);
125
+ if (isVerificationCodeComplete(nextCells)) {
126
+ onComplete?.(code);
122
127
  }
123
128
  },
124
- [isControlled, onComplete, onValueChange, safeLength],
129
+ [onComplete, onValueChange],
125
130
  );
126
131
 
127
132
  useImperativeHandle(
128
133
  forwardedRef,
129
134
  () => ({
130
135
  clear: () => {
131
- commit("");
136
+ commit(toVerificationCells("", safeLength));
132
137
  focus(0);
133
138
  },
134
139
  focus,
135
140
  }),
136
- [commit, focus],
141
+ [commit, focus, safeLength],
137
142
  );
138
143
 
139
144
  const handleChange = (index: number, input: string) => {
140
- const update = updateVerificationCode(currentCode, index, input, safeLength);
141
- commit(update.code);
145
+ const update = updateVerificationCells(cells, index, input, safeLength);
146
+ commit(update.cells);
142
147
  focus(update.nextFocus);
143
148
  };
144
149
 
@@ -148,9 +153,9 @@ export const VerificationCodeInput = forwardRef<
148
153
  ) => {
149
154
  const key = event.nativeEvent.key;
150
155
 
151
- if (key === "Backspace" && !currentCode[index] && index > 0) {
152
- const update = updateVerificationCode(currentCode, index - 1, "", safeLength);
153
- commit(update.code);
156
+ if (key === "Backspace" && !cells[index] && index > 0) {
157
+ const update = updateVerificationCells(cells, index - 1, "", safeLength);
158
+ commit(update.cells);
154
159
  focus(index - 1);
155
160
  return;
156
161
  }
@@ -223,7 +228,7 @@ export const VerificationCodeInput = forwardRef<
223
228
  Platform.OS === "android" ? styles.cellAndroid : null,
224
229
  inputStyle,
225
230
  ]}
226
- value={currentCode[index] ?? ""}
231
+ value={cells[index] ?? ""}
227
232
  />
228
233
  );
229
234
  })}
@@ -2,33 +2,73 @@ export function normalizeVerificationCode(value: string, length: number): string
2
2
  return value.replace(/\D/g, "").slice(0, Math.max(1, length));
3
3
  }
4
4
 
5
- export interface VerificationCodeUpdate {
6
- code: string;
5
+ /** Fills cells from the start of `code`; one entry per cell, `""` marks an empty cell. */
6
+ export function toVerificationCells(code: string, length: number): string[] {
7
+ const safeLength = Math.max(1, length);
8
+ const digits = normalizeVerificationCode(code, safeLength);
9
+ return Array.from({ length: safeLength }, (_, index) => digits[index] ?? "");
10
+ }
11
+
12
+ /** The code reported to consumers: the entered digits in cell order, gaps skipped. */
13
+ export function verificationCellsToCode(cells: readonly string[]): string {
14
+ return cells.join("");
15
+ }
16
+
17
+ export function isVerificationCodeComplete(cells: readonly string[]): boolean {
18
+ return cells.length > 0 && cells.every((cell) => cell !== "");
19
+ }
20
+
21
+ /**
22
+ * Keeps the displayed cells, including gaps, while they still produce `code`;
23
+ * otherwise (a new value from the parent, or a new length) lays `code` out again.
24
+ */
25
+ export function reconcileVerificationCells(
26
+ cells: readonly string[],
27
+ code: string,
28
+ length: number,
29
+ ): string[] {
30
+ const safeLength = Math.max(1, length);
31
+ const normalized = normalizeVerificationCode(code, safeLength);
32
+ if (cells.length === safeLength && verificationCellsToCode(cells) === normalized) {
33
+ return [...cells];
34
+ }
35
+ return toVerificationCells(normalized, safeLength);
36
+ }
37
+
38
+ export interface VerificationCellsUpdate {
39
+ cells: string[];
7
40
  nextFocus: number;
8
41
  }
9
42
 
10
- export function updateVerificationCode(
11
- currentCode: string,
43
+ /**
44
+ * Applies input typed or pasted into cell `index`. Clearing a cell leaves a gap
45
+ * instead of shifting later digits left; pasted digits fill forward from `index`.
46
+ */
47
+ export function updateVerificationCells(
48
+ currentCells: readonly string[],
12
49
  index: number,
13
50
  input: string,
14
51
  length: number,
15
- ): VerificationCodeUpdate {
52
+ ): VerificationCellsUpdate {
16
53
  const safeLength = Math.max(1, length);
17
54
  const safeIndex = Math.min(Math.max(0, index), safeLength - 1);
18
- const current = normalizeVerificationCode(currentCode, safeLength);
55
+ const cells = Array.from({ length: safeLength }, (_, cellIndex) => {
56
+ const cell = currentCells[cellIndex] ?? "";
57
+ return /^\d$/.test(cell) ? cell : "";
58
+ });
19
59
  const insertedDigits = normalizeVerificationCode(input, safeLength - safeIndex);
20
60
 
21
61
  if (!insertedDigits) {
22
- return {
23
- code: `${current.slice(0, safeIndex)}${current.slice(safeIndex + 1)}`,
24
- nextFocus: safeIndex,
25
- };
62
+ cells[safeIndex] = "";
63
+ return { cells, nextFocus: safeIndex };
26
64
  }
27
65
 
66
+ Array.from(insertedDigits).forEach((digit, offset) => {
67
+ cells[safeIndex + offset] = digit;
68
+ });
69
+
28
70
  return {
29
- code: `${current.slice(0, safeIndex)}${insertedDigits}${current.slice(
30
- safeIndex + insertedDigits.length,
31
- )}`.slice(0, safeLength),
71
+ cells,
32
72
  nextFocus: Math.min(safeIndex + insertedDigits.length, safeLength - 1),
33
73
  };
34
74
  }
@@ -0,0 +1,14 @@
1
+ import Svg, { Path } from "react-native-svg";
2
+ import { colors } from "../../theme";
3
+ import type { IconProps } from "../types";
4
+ import { PAYMENT_CARD_PATH } from "./paymentCardPath";
5
+
6
+ export function PaymentCardIcon({ size = 16, color = colors.grey100 }: IconProps) {
7
+ return (
8
+ <Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
9
+ <Path d={PAYMENT_CARD_PATH} fill={color} />
10
+ </Svg>
11
+ );
12
+ }
13
+
14
+ export { PAYMENT_CARD_PATH } from "./paymentCardPath";
@@ -0,0 +1,13 @@
1
+ import { colors } from "../../theme";
2
+ import type { IconProps } from "../types";
3
+ import { PAYMENT_CARD_PATH } from "./paymentCardPath";
4
+
5
+ export function PaymentCardIcon({ size = 16, color = colors.grey100 }: IconProps) {
6
+ return (
7
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
8
+ <path d={PAYMENT_CARD_PATH} fill={color} />
9
+ </svg>
10
+ );
11
+ }
12
+
13
+ export { PAYMENT_CARD_PATH } from "./paymentCardPath";
@@ -0,0 +1 @@
1
+ export { PaymentCardIcon, PAYMENT_CARD_PATH } from "./PaymentCardIcon";
@@ -0,0 +1,3 @@
1
+ /** Filled payment card icon path — 16×16 viewBox. */
2
+ export const PAYMENT_CARD_PATH =
3
+ "M4.66536 10H6.66536C6.84218 10 7.01174 9.9298 7.13677 9.80478C7.26179 9.67975 7.33203 9.51019 7.33203 9.33337C7.33203 9.15656 7.26179 8.98699 7.13677 8.86197C7.01174 8.73695 6.84218 8.66671 6.66536 8.66671H4.66536C4.48855 8.66671 4.31898 8.73695 4.19396 8.86197C4.06894 8.98699 3.9987 9.15656 3.9987 9.33337C3.9987 9.51019 4.06894 9.67975 4.19396 9.80478C4.31898 9.9298 4.48855 10 4.66536 10ZM12.6654 3.33337H3.33203C2.8016 3.33337 2.29289 3.54409 1.91782 3.91916C1.54274 4.29423 1.33203 4.80294 1.33203 5.33337V11.3334C1.33203 11.8638 1.54274 12.3725 1.91782 12.7476C2.29289 13.1227 2.8016 13.3334 3.33203 13.3334H12.6654C13.1958 13.3334 13.7045 13.1227 14.0796 12.7476C14.4546 12.3725 14.6654 11.8638 14.6654 11.3334V5.33337C14.6654 4.80294 14.4546 4.29423 14.0796 3.91916C13.7045 3.54409 13.1958 3.33337 12.6654 3.33337ZM13.332 11.3334C13.332 11.5102 13.2618 11.6798 13.1368 11.8048C13.0117 11.9298 12.8422 12 12.6654 12H3.33203C3.15522 12 2.98565 11.9298 2.86063 11.8048C2.7356 11.6798 2.66536 11.5102 2.66536 11.3334V7.33337H13.332V11.3334ZM13.332 6.00004H2.66536V5.33337C2.66536 5.15656 2.7356 4.98699 2.86063 4.86197C2.98565 4.73695 3.15522 4.66671 3.33203 4.66671H12.6654C12.8422 4.66671 13.0117 4.73695 13.1368 4.86197C13.2618 4.98699 13.332 5.15656 13.332 5.33337V6.00004Z";
@@ -0,0 +1,14 @@
1
+ import Svg, { Path } from "react-native-svg";
2
+ import { colors } from "../../theme";
3
+ import type { IconProps } from "../types";
4
+ import { RECEIPT_PATH } from "./receiptPath";
5
+
6
+ export function ReceiptIcon({ size = 16, color = colors.grey100 }: IconProps) {
7
+ return (
8
+ <Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
9
+ <Path d={RECEIPT_PATH} fill={color} />
10
+ </Svg>
11
+ );
12
+ }
13
+
14
+ export { RECEIPT_PATH } from "./receiptPath";
@@ -0,0 +1,13 @@
1
+ import { colors } from "../../theme";
2
+ import type { IconProps } from "../types";
3
+ import { RECEIPT_PATH } from "./receiptPath";
4
+
5
+ export function ReceiptIcon({ size = 16, color = colors.grey100 }: IconProps) {
6
+ return (
7
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
8
+ <path d={RECEIPT_PATH} fill={color} />
9
+ </svg>
10
+ );
11
+ }
12
+
13
+ export { RECEIPT_PATH } from "./receiptPath";
@@ -0,0 +1 @@
1
+ export { ReceiptIcon, RECEIPT_PATH } from "./ReceiptIcon";
@@ -0,0 +1,3 @@
1
+ /** Filled receipt icon path — 16×16 viewBox. */
2
+ export const RECEIPT_PATH =
3
+ "M6.66667 5.33337H10.6667C10.8435 5.33337 11.013 5.26314 11.1381 5.13811C11.2631 5.01309 11.3333 4.84352 11.3333 4.66671C11.3333 4.4899 11.2631 4.32033 11.1381 4.1953C11.013 4.07028 10.8435 4.00004 10.6667 4.00004H6.66667C6.48986 4.00004 6.32029 4.07028 6.19526 4.1953C6.07024 4.32033 6 4.4899 6 4.66671C6 4.84352 6.07024 5.01309 6.19526 5.13811C6.32029 5.26314 6.48986 5.33337 6.66667 5.33337ZM5.33333 8.00004H10.6667C10.8435 8.00004 11.013 7.9298 11.1381 7.80478C11.2631 7.67975 11.3333 7.51019 11.3333 7.33337C11.3333 7.15656 11.2631 6.98699 11.1381 6.86197C11.013 6.73695 10.8435 6.66671 10.6667 6.66671H5.33333C5.15652 6.66671 4.98695 6.73695 4.86193 6.86197C4.7369 6.98699 4.66667 7.15656 4.66667 7.33337C4.66667 7.51019 4.7369 7.67975 4.86193 7.80478C4.98695 7.9298 5.15652 8.00004 5.33333 8.00004ZM5.33333 10.6667H10.6667C10.8435 10.6667 11.013 10.5965 11.1381 10.4714C11.2631 10.3464 11.3333 10.1769 11.3333 10C11.3333 9.82323 11.2631 9.65366 11.1381 9.52864C11.013 9.40361 10.8435 9.33337 10.6667 9.33337H5.33333C5.15652 9.33337 4.98695 9.40361 4.86193 9.52864C4.7369 9.65366 4.66667 9.82323 4.66667 10C4.66667 10.1769 4.7369 10.3464 4.86193 10.4714C4.98695 10.5965 5.15652 10.6667 5.33333 10.6667ZM13.3333 1.33337H2.66667C2.48986 1.33337 2.32029 1.40361 2.19526 1.52864C2.07024 1.65366 2 1.82323 2 2.00004V14C2 14.1238 2.03448 14.2452 2.09957 14.3505C2.16466 14.4558 2.25779 14.541 2.36852 14.5963C2.47926 14.6517 2.60323 14.6751 2.72654 14.664C2.84984 14.6529 2.96762 14.6077 3.06667 14.5334L4.44667 13.5L5.82 14.5334C5.9354 14.6199 6.07575 14.6667 6.22 14.6667C6.36425 14.6667 6.5046 14.6199 6.62 14.5334L8 13.5L9.38 14.5334C9.4954 14.6199 9.63575 14.6667 9.78 14.6667C9.92425 14.6667 10.0646 14.6199 10.18 14.5334L11.5533 13.5L12.9333 14.5334C13.0329 14.6075 13.1511 14.6525 13.2748 14.6631C13.3985 14.6737 13.5226 14.6495 13.6333 14.5934C13.7433 14.538 13.8357 14.4532 13.9005 14.3485C13.9652 14.2438 13.9996 14.1232 14 14V2.00004C14 1.82323 13.9298 1.65366 13.8047 1.52864C13.6797 1.40361 13.5101 1.33337 13.3333 1.33337ZM12.6667 12.6667L11.9533 12.1334C11.8379 12.0468 11.6976 12 11.5533 12C11.4091 12 11.2687 12.0468 11.1533 12.1334L9.78 13.1667L8.4 12.1334C8.2846 12.0468 8.14425 12 8 12C7.85575 12 7.7154 12.0468 7.6 12.1334L6.22 13.1667L4.84667 12.1334C4.73127 12.0468 4.59091 12 4.44667 12C4.30242 12 4.16206 12.0468 4.04667 12.1334L3.33333 12.6667V2.66671H12.6667V12.6667Z";
@@ -102,9 +102,11 @@ export { NotificationQrCodeIcon } from "./NotificationQrCode";
102
102
  export { NotificationSuccessIcon } from "./NotificationSuccess";
103
103
  export { NotificationWarningIcon } from "./NotificationWarning";
104
104
  export { PhoneIcon } from "./Phone";
105
+ export { PaymentCardIcon } from "./PaymentCard";
105
106
  export { PlusIcon } from "./Plus";
106
107
  export { MinusIcon } from "./Minus";
107
108
  export { RefreshIcon } from "./Refresh";
109
+ export { ReceiptIcon } from "./Receipt";
108
110
  export { RefundStatusIcon } from "./RefundStatus";
109
111
  export { SearchIcon } from "./Search";
110
112
  export { SettingsIcon } from "./Settings";
@@ -90,8 +90,10 @@ import { NotificationQrCodeIcon } from "./NotificationQrCode";
90
90
  import { NotificationSuccessIcon } from "./NotificationSuccess";
91
91
  import { NotificationWarningIcon } from "./NotificationWarning";
92
92
  import { PhoneIcon } from "./Phone";
93
+ import { PaymentCardIcon } from "./PaymentCard";
93
94
  import { PlusIcon } from "./Plus";
94
95
  import { RefreshIcon } from "./Refresh";
96
+ import { ReceiptIcon } from "./Receipt";
95
97
  import { RefundStatusIcon } from "./RefundStatus";
96
98
  import { SearchIcon } from "./Search";
97
99
  import { SettingsIcon } from "./Settings";
@@ -191,12 +193,14 @@ export const icons = {
191
193
  notificationSuccess: NotificationSuccessIcon,
192
194
  notificationWarning: NotificationWarningIcon,
193
195
  paw: PawIcon,
196
+ paymentCard: PaymentCardIcon,
194
197
  phone: PhoneIcon,
195
198
  qrCode: QrCodeIcon,
196
199
  quietHours: QuietHoursIcon,
197
200
  ratingStar: RatingStarIcon,
198
201
  plus: PlusIcon,
199
202
  refresh: RefreshIcon,
203
+ receipt: ReceiptIcon,
200
204
  refundStatus: RefundStatusIcon,
201
205
  search: SearchIcon,
202
206
  saveHeart: SaveHeartIcon,
package/src/index.ts CHANGED
@@ -225,8 +225,10 @@ export {
225
225
  NotificationSuccessIcon,
226
226
  NotificationWarningIcon,
227
227
  PhoneIcon,
228
+ PaymentCardIcon,
228
229
  PlusIcon,
229
230
  RefreshIcon,
231
+ ReceiptIcon,
230
232
  RefundStatusIcon,
231
233
  SearchIcon,
232
234
  SettingsIcon,
@@ -291,6 +293,11 @@ export {
291
293
  } from "./theme";
292
294
  export type { GreyStep } from "./theme";
293
295
 
294
- export { abbreviateMonthName, formatDate, formatDateDayShortMonth } from "./utils/dateUtils";
296
+ export {
297
+ abbreviateMonthName,
298
+ abbreviateMonthNames,
299
+ formatDate,
300
+ formatDateDayShortMonth,
301
+ } from "./utils/dateUtils";
295
302
  export { resolvePopoverAlign } from "./utils/popoverAlign";
296
303
  export type { PopoverAlign, ResolvePopoverAlignOptions } from "./utils/popoverAlign";
@@ -47,12 +47,12 @@ export const segmentedToggleTypography = {
47
47
  letterSpacing: 0,
48
48
  } as const;
49
49
 
50
- /** Language switcher trigger and option labels — SemiBold 14, tracking 3%. */
50
+ /** Language switcher trigger and option labels — SemiBold 14, 18px line-height, tracking 3%. */
51
51
  export const languageSwitcherTypography = {
52
52
  fontFamily: fonts.sans,
53
53
  fontSize: 14,
54
54
  fontWeight: "600" as const,
55
- lineHeight: 14,
55
+ lineHeight: 18,
56
56
  letterSpacing: 0.42,
57
57
  } as const;
58
58
 
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { abbreviateMonthNames, formatDateDayShortMonth } from "./dateUtils";
3
+
4
+ const ARMENIAN_MONTHS = [
5
+ "Հունվար",
6
+ "Փետրվար",
7
+ "Մարտ",
8
+ "Ապրիլ",
9
+ "Մայիս",
10
+ "Հունիս",
11
+ "Հուլիս",
12
+ "Օգոստոս",
13
+ "Սեպտեմբեր",
14
+ "Հոկտեմբեր",
15
+ "Նոյեմբեր",
16
+ "Դեկտեմբեր",
17
+ ];
18
+
19
+ const ENGLISH_MONTHS = [
20
+ "January",
21
+ "February",
22
+ "March",
23
+ "April",
24
+ "May",
25
+ "June",
26
+ "July",
27
+ "August",
28
+ "September",
29
+ "October",
30
+ "November",
31
+ "December",
32
+ ];
33
+
34
+ describe("abbreviateMonthNames", () => {
35
+ it("gives Armenian January and June distinct abbreviations", () => {
36
+ const abbreviations = abbreviateMonthNames(ARMENIAN_MONTHS);
37
+
38
+ expect(abbreviations[0]).toBe("Հունվ");
39
+ expect(abbreviations[5]).toBe("Հունի");
40
+ expect(abbreviations[6]).toBe("Հուլ");
41
+ expect(new Set(abbreviations).size).toBe(12);
42
+ });
43
+
44
+ it("keeps three letters when they are already unique", () => {
45
+ expect(abbreviateMonthNames(ENGLISH_MONTHS)).toEqual([
46
+ "Jan",
47
+ "Feb",
48
+ "Mar",
49
+ "Apr",
50
+ "May",
51
+ "Jun",
52
+ "Jul",
53
+ "Aug",
54
+ "Sep",
55
+ "Oct",
56
+ "Nov",
57
+ "Dec",
58
+ ]);
59
+ });
60
+
61
+ it("stops at the full name when two names are identical", () => {
62
+ expect(abbreviateMonthNames(["Same", "Same"])).toEqual(["Same", "Same"]);
63
+ });
64
+ });
65
+
66
+ describe("formatDateDayShortMonth", () => {
67
+ it("formats Armenian January and June unambiguously", () => {
68
+ expect(formatDateDayShortMonth(new Date(2026, 0, 15), ARMENIAN_MONTHS)).toBe("15 Հունվ 2026");
69
+ expect(formatDateDayShortMonth(new Date(2026, 5, 15), ARMENIAN_MONTHS)).toBe("15 Հունի 2026");
70
+ expect(formatDateDayShortMonth(new Date(2026, 6, 15), ARMENIAN_MONTHS)).toBe("15 Հուլ 2026");
71
+ });
72
+ });
@@ -76,9 +76,37 @@ export function abbreviateMonthName(monthName: string, letterCount = 3): string
76
76
  return chars.slice(0, end).join("");
77
77
  }
78
78
 
79
- /** "15 Հուլ 2026" — day + abbreviated month + year for range triggers. */
79
+ /**
80
+ * Abbreviates every month name to at least `letterCount` letters, adding letters
81
+ * where two months would otherwise share an abbreviation: Armenian
82
+ * «Հունվար» / «Հունիս» become «Հունվ» / «Հունի» instead of both «Հուն».
83
+ */
84
+ export function abbreviateMonthNames(monthNames: readonly string[], letterCount = 3): string[] {
85
+ return monthNames.map((name, index) => {
86
+ const maxLetters = abbreviatedLetterCount(name);
87
+ let count = letterCount;
88
+ while (
89
+ count < maxLetters &&
90
+ monthNames.some(
91
+ (other, otherIndex) =>
92
+ otherIndex !== index &&
93
+ abbreviateMonthName(other, count) === abbreviateMonthName(name, count),
94
+ )
95
+ ) {
96
+ count += 1;
97
+ }
98
+ return abbreviateMonthName(name, count);
99
+ });
100
+ }
101
+
102
+ /** Letter count of `monthName`, counting the Armenian digraph «ու» as one letter. */
103
+ function abbreviatedLetterCount(monthName: string): number {
104
+ return Array.from(monthName.replace(/ու/g, "u")).length;
105
+ }
106
+
107
+ /** "15 Հուլ 2026" — day + unambiguous abbreviated month + year for range triggers. */
80
108
  export function formatDateDayShortMonth(date: Date, monthNames: string[], letterCount = 3): string {
81
- const month = abbreviateMonthName(monthNames[date.getMonth()] ?? "", letterCount);
109
+ const month = abbreviateMonthNames(monthNames, letterCount)[date.getMonth()] ?? "";
82
110
  return `${date.getDate()} ${month} ${date.getFullYear()}`;
83
111
  }
84
112
 
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getWrappedFocusIndex } from "./focusTrap";
3
+
4
+ describe("getWrappedFocusIndex", () => {
5
+ it("wraps Tab from the last element to the first", () => {
6
+ expect(getWrappedFocusIndex(3, 2, false)).toBe(0);
7
+ });
8
+
9
+ it("wraps Shift+Tab from the first element to the last", () => {
10
+ expect(getWrappedFocusIndex(3, 0, true)).toBe(2);
11
+ });
12
+
13
+ it("leaves Tab between inner elements to the browser", () => {
14
+ expect(getWrappedFocusIndex(3, 1, false)).toBeNull();
15
+ expect(getWrappedFocusIndex(3, 1, true)).toBeNull();
16
+ });
17
+
18
+ it("enters from the dialog container itself in either direction", () => {
19
+ expect(getWrappedFocusIndex(3, -1, false)).toBe(0);
20
+ expect(getWrappedFocusIndex(3, -1, true)).toBe(2);
21
+ });
22
+
23
+ it("has nothing to move to without focusable content", () => {
24
+ expect(getWrappedFocusIndex(0, -1, false)).toBeNull();
25
+ });
26
+ });
@@ -0,0 +1,39 @@
1
+ const TABBABLE_SELECTOR = [
2
+ "a[href]",
3
+ "area[href]",
4
+ "button",
5
+ "input:not([type='hidden'])",
6
+ "select",
7
+ "textarea",
8
+ "iframe",
9
+ "[contenteditable='true']",
10
+ "[tabindex]",
11
+ ].join(",");
12
+
13
+ /** Focusable elements inside `container` that Tab can reach, in DOM order. */
14
+ export function getTabbableElements(container: HTMLElement): HTMLElement[] {
15
+ return Array.from(container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter(
16
+ (element) =>
17
+ element.tabIndex >= 0 &&
18
+ !element.hasAttribute("disabled") &&
19
+ element.getAttribute("aria-hidden") !== "true" &&
20
+ element.getClientRects().length > 0,
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Index to move focus to when Tab would leave the trap, or `null` when the
26
+ * browser's own Tab order already stays inside. `currentIndex` is `-1` when
27
+ * focus is on the container itself (or outside it).
28
+ */
29
+ export function getWrappedFocusIndex(
30
+ count: number,
31
+ currentIndex: number,
32
+ backwards: boolean,
33
+ ): number | null {
34
+ if (count === 0) return null;
35
+ if (currentIndex === -1) return backwards ? count - 1 : 0;
36
+ if (backwards && currentIndex === 0) return count - 1;
37
+ if (!backwards && currentIndex === count - 1) return 0;
38
+ return null;
39
+ }