@transferwise/components 46.160.1 → 46.160.3

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 (114) hide show
  1. package/build/Calendar/Calendar.js +9 -3
  2. package/build/Calendar/Calendar.js.map +1 -1
  3. package/build/Calendar/Calendar.mjs +9 -3
  4. package/build/Calendar/Calendar.mjs.map +1 -1
  5. package/build/Carousel/Carousel.js +17 -10
  6. package/build/Carousel/Carousel.js.map +1 -1
  7. package/build/Carousel/Carousel.mjs +17 -10
  8. package/build/Carousel/Carousel.mjs.map +1 -1
  9. package/build/Inputs/SelectInput/Options/SelectInputOptions.js +33 -5
  10. package/build/Inputs/SelectInput/Options/SelectInputOptions.js.map +1 -1
  11. package/build/Inputs/SelectInput/Options/SelectInputOptions.mjs +34 -6
  12. package/build/Inputs/SelectInput/Options/SelectInputOptions.mjs.map +1 -1
  13. package/build/Inputs/SelectInput/SelectInput.js +28 -4
  14. package/build/Inputs/SelectInput/SelectInput.js.map +1 -1
  15. package/build/Inputs/SelectInput/SelectInput.mjs +29 -5
  16. package/build/Inputs/SelectInput/SelectInput.mjs.map +1 -1
  17. package/build/Inputs/SelectInput/SelectInput.utils.js +0 -2
  18. package/build/Inputs/SelectInput/SelectInput.utils.js.map +1 -1
  19. package/build/Inputs/SelectInput/SelectInput.utils.mjs +1 -2
  20. package/build/Inputs/SelectInput/SelectInput.utils.mjs.map +1 -1
  21. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js +5 -1
  22. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
  23. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs +5 -1
  24. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
  25. package/build/Inputs/SelectInput/constants.js +8 -0
  26. package/build/Inputs/SelectInput/constants.js.map +1 -0
  27. package/build/Inputs/SelectInput/constants.mjs +5 -0
  28. package/build/Inputs/SelectInput/constants.mjs.map +1 -0
  29. package/build/Inputs/SelectInput/hooks/useTypeahead.js +57 -0
  30. package/build/Inputs/SelectInput/hooks/useTypeahead.js.map +1 -0
  31. package/build/Inputs/SelectInput/hooks/useTypeahead.mjs +54 -0
  32. package/build/Inputs/SelectInput/hooks/useTypeahead.mjs.map +1 -0
  33. package/build/SegmentedControl/SegmentedControl.js +9 -2
  34. package/build/SegmentedControl/SegmentedControl.js.map +1 -1
  35. package/build/SegmentedControl/SegmentedControl.mjs +9 -2
  36. package/build/SegmentedControl/SegmentedControl.mjs.map +1 -1
  37. package/build/Tooltip/Tooltip.js +6 -1
  38. package/build/Tooltip/Tooltip.js.map +1 -1
  39. package/build/Tooltip/Tooltip.mjs +6 -1
  40. package/build/Tooltip/Tooltip.mjs.map +1 -1
  41. package/build/i18n/ja.json +1 -1
  42. package/build/i18n/ja.json.js +1 -1
  43. package/build/i18n/ja.json.mjs +1 -1
  44. package/build/i18n/tr.json +5 -0
  45. package/build/i18n/tr.json.js +5 -0
  46. package/build/i18n/tr.json.js.map +1 -1
  47. package/build/i18n/tr.json.mjs +5 -0
  48. package/build/i18n/tr.json.mjs.map +1 -1
  49. package/build/i18n/zh-CN.json +5 -0
  50. package/build/i18n/zh-CN.json.js +5 -0
  51. package/build/i18n/zh-CN.json.js.map +1 -1
  52. package/build/i18n/zh-CN.json.mjs +5 -0
  53. package/build/i18n/zh-CN.json.mjs.map +1 -1
  54. package/build/main.css +34 -22
  55. package/build/styles/AvatarLayout/AvatarLayout.css +26 -9
  56. package/build/styles/SegmentedControl/SegmentedControl.css +2 -1
  57. package/build/styles/Tooltip/Tooltip.css +0 -6
  58. package/build/styles/common/Panel/Panel.css +6 -6
  59. package/build/styles/main.css +34 -22
  60. package/build/types/Calendar/Calendar.d.ts.map +1 -1
  61. package/build/types/Carousel/Carousel.d.ts.map +1 -1
  62. package/build/types/Carousel/_stories/constants.d.ts +3 -0
  63. package/build/types/Carousel/_stories/constants.d.ts.map +1 -0
  64. package/build/types/Inputs/SelectInput/Options/SelectInputOptions.d.ts +3 -1
  65. package/build/types/Inputs/SelectInput/Options/SelectInputOptions.d.ts.map +1 -1
  66. package/build/types/Inputs/SelectInput/SelectInput.d.ts.map +1 -1
  67. package/build/types/Inputs/SelectInput/SelectInput.utils.d.ts +0 -1
  68. package/build/types/Inputs/SelectInput/SelectInput.utils.d.ts.map +1 -1
  69. package/build/types/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts.map +1 -1
  70. package/build/types/Inputs/SelectInput/constants.d.ts +3 -0
  71. package/build/types/Inputs/SelectInput/constants.d.ts.map +1 -0
  72. package/build/types/Inputs/SelectInput/hooks/useTypeahead.d.ts +8 -0
  73. package/build/types/Inputs/SelectInput/hooks/useTypeahead.d.ts.map +1 -0
  74. package/build/types/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  75. package/build/types/Tooltip/Tooltip.d.ts.map +1 -1
  76. package/package.json +4 -4
  77. package/src/AvatarLayout/AvatarLayout.css +26 -9
  78. package/src/AvatarLayout/AvatarLayout.less +46 -22
  79. package/src/AvatarLayout/_stories/AvatarLayout.test.story.tsx +17 -42
  80. package/src/Calendar/Calendar.test.tsx +82 -0
  81. package/src/Calendar/Calendar.tsx +6 -3
  82. package/src/Carousel/Carousel.test.tsx +50 -2
  83. package/src/Carousel/Carousel.tsx +13 -10
  84. package/src/Carousel/_stories/Carousel.story.tsx +12 -52
  85. package/src/Carousel/_stories/Carousel.test.story.tsx +7 -43
  86. package/src/Carousel/_stories/constants.tsx +46 -0
  87. package/src/DateLookup/_stories/DateLookup.rtl.test.story.tsx +59 -48
  88. package/src/Field/_stories/Field.test.story.tsx +48 -37
  89. package/src/FlowNavigation/_stories/FlowNavigation.rtl.test.story.tsx +78 -0
  90. package/src/FlowNavigation/_stories/FlowNavigation.test.story.tsx +0 -44
  91. package/src/Inputs/SelectInput/Options/SelectInputOptions.tsx +36 -6
  92. package/src/Inputs/SelectInput/SelectInput.test.tsx +143 -0
  93. package/src/Inputs/SelectInput/SelectInput.tsx +31 -5
  94. package/src/Inputs/SelectInput/SelectInput.utils.ts +0 -2
  95. package/src/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +3 -0
  96. package/src/Inputs/SelectInput/constants.ts +3 -0
  97. package/src/Inputs/SelectInput/hooks/useTypeahead.ts +82 -0
  98. package/src/Label/_stories/Label.test.story.tsx +35 -22
  99. package/src/Popover/_stories/Popover.rtl.test.story.tsx +40 -50
  100. package/src/SegmentedControl/SegmentedControl.css +2 -1
  101. package/src/SegmentedControl/SegmentedControl.less +4 -2
  102. package/src/SegmentedControl/SegmentedControl.tsx +8 -2
  103. package/src/SegmentedControl/_stories/SegmentedControl.test.story.tsx +16 -8
  104. package/src/Tooltip/Tooltip.css +0 -6
  105. package/src/Tooltip/Tooltip.less +0 -12
  106. package/src/Tooltip/Tooltip.test.tsx +51 -0
  107. package/src/Tooltip/Tooltip.tsx +11 -1
  108. package/src/Tooltip/_stories/Tooltip.rtl.test.story.tsx +16 -34
  109. package/src/common/Panel/Panel.css +6 -6
  110. package/src/common/Panel/Panel.less +6 -6
  111. package/src/i18n/ja.json +1 -1
  112. package/src/i18n/tr.json +5 -0
  113. package/src/i18n/zh-CN.json +5 -0
  114. package/src/main.css +34 -22
@@ -1,5 +1,5 @@
1
1
  import mergeProps from 'merge-props';
2
- import { useCallback, useEffect, useRef, useState, useDeferredValue } from 'react';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { Listbox as ListboxBase } from '@headlessui/react';
4
4
  import { Breakpoint } from '@transferwise/neptune-tokens';
5
5
  import { useScreenSize } from '../../common/hooks/useScreenSize';
@@ -10,6 +10,7 @@ import { SelectInputBottomSheet } from './BottomSheet';
10
10
  import { SelectInputPopover } from './Popover';
11
11
  import { SelectInputOptions } from './Options';
12
12
  import { DefaultRenderTrigger } from './DefaultRenderTrigger';
13
+ import { isTypingKey } from './hooks/useTypeahead';
13
14
 
14
15
  import {
15
16
  SelectInputOptionContentWithinTriggerContext,
@@ -74,7 +75,6 @@ export function SelectInput<T = string, M extends boolean = false>({
74
75
  }, [open]);
75
76
 
76
77
  const [filterQuery, _setFilterQuery] = useState('');
77
- const deferredFilterQuery = useDeferredValue(filterQuery);
78
78
  const previousFilterQueryRef = useRef(filterQuery);
79
79
 
80
80
  const setFilterQuery = useCallback(
@@ -94,6 +94,7 @@ export function SelectInput<T = string, M extends boolean = false>({
94
94
  const internalTriggerRef = useRef<HTMLButtonElement | null>(null);
95
95
  const searchInputRef = useRef<HTMLInputElement>(null);
96
96
  const listboxRef = useRef<HTMLDivElement>(null);
97
+ const [initialTypeaheadKey, setInitialTypeaheadKey] = useState<string | null>(null);
97
98
  const controllerRef = filterable ? searchInputRef : listboxRef;
98
99
 
99
100
  const screenSm = useScreenSize(Breakpoint.SMALL);
@@ -175,13 +176,33 @@ export function SelectInput<T = string, M extends boolean = false>({
175
176
  setOpen((prev) => !prev);
176
177
  },
177
178
  onKeyDown: (event: React.KeyboardEvent) => {
179
+ if (event.key === 'Enter') {
180
+ event.preventDefault();
181
+ event.currentTarget.dispatchEvent(
182
+ new KeyboardEvent('keydown', { key: ' ', bubbles: true }),
183
+ );
184
+ return;
185
+ }
186
+
187
+ if (!open && isTypingKey(event)) {
188
+ event.preventDefault();
189
+
190
+ if (filterable) {
191
+ setFilterQuery(event.key);
192
+ } else {
193
+ setInitialTypeaheadKey(event.key);
194
+ }
195
+
196
+ (event.currentTarget as HTMLButtonElement).click();
197
+ return;
198
+ }
199
+
178
200
  if (
179
201
  event.key === ' ' ||
180
- event.key === 'Enter' ||
181
202
  event.key === 'ArrowDown' ||
182
203
  event.key === 'ArrowUp'
183
204
  ) {
184
- setOpen((prev) => !prev);
205
+ setOpen(true);
185
206
  }
186
207
  },
187
208
  },
@@ -225,6 +246,7 @@ export function SelectInput<T = string, M extends boolean = false>({
225
246
  setOpen(false);
226
247
  }}
227
248
  onCloseEnd={() => {
249
+ setInitialTypeaheadKey(null);
228
250
  setFilterQuery('');
229
251
  }}
230
252
  >
@@ -240,10 +262,14 @@ export function SelectInput<T = string, M extends boolean = false>({
240
262
  sortFilteredOptions={sortFilteredOptions}
241
263
  searchInputRef={searchInputRef}
242
264
  listboxRef={listboxRef}
243
- filterQuery={deferredFilterQuery}
265
+ filterQuery={filterQuery}
266
+ initialTypeaheadKey={initialTypeaheadKey}
244
267
  autocomplete={autocomplete}
245
268
  name={name}
246
269
  onFilterChange={setFilterQuery}
270
+ onInitialTypeaheadHandled={() => {
271
+ setInitialTypeaheadKey(null);
272
+ }}
247
273
  onAutocompleteSelect={(matchedValue) => {
248
274
  onChange?.(matchedValue as M extends true ? T[] : T);
249
275
  if (!multiple) {
@@ -1,7 +1,5 @@
1
1
  import { SelectInputItem, SelectInputOptionItem } from './SelectInput.types';
2
2
 
3
- export const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
4
-
5
3
  /**
6
4
  * Converts a string to a normalized, searchable format by:
7
5
  * - Trimming whitespace
@@ -29,6 +29,9 @@ export function SelectInputTriggerButton<T extends SelectInputTriggerButtonEleme
29
29
  ref={ref}
30
30
  as={PolymorphicWithOverrides}
31
31
  role="combobox"
32
+ // Safari can omit buttons from sequential keyboard navigation unless their
33
+ // focus order is explicit. Zero keeps the trigger in the natural tab order.
34
+ tabIndex={0}
32
35
  __overrides={{ as, size, ...interactionProps }}
33
36
  {...mergeProps({ onClick, onKeyDown }, restProps)}
34
37
  />
@@ -0,0 +1,3 @@
1
+ export const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
2
+
3
+ export const TYPEAHEAD_QUERY_RESET_TIMEOUT_MS = 350;
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
3
+ import { TYPEAHEAD_QUERY_RESET_TIMEOUT_MS } from '../constants';
4
+ import { searchableString } from '../SelectInput.utils';
5
+
6
+ const resetsTypeaheadQuery = (key: string) =>
7
+ key === 'ArrowDown' ||
8
+ key === 'ArrowUp' ||
9
+ key === 'Home' ||
10
+ key === 'End' ||
11
+ key === 'PageDown' ||
12
+ key === 'PageUp';
13
+
14
+ export const isTypingKey = (event: React.KeyboardEvent, query = '') =>
15
+ !event.metaKey &&
16
+ !event.ctrlKey &&
17
+ !event.nativeEvent.isComposing &&
18
+ /^.$/u.test(event.key) &&
19
+ (event.key !== ' ' || query !== '');
20
+
21
+ interface UseTypeaheadParams {
22
+ disabled?: boolean;
23
+ onMatch?: () => void;
24
+ }
25
+
26
+ export function useTypeahead({ disabled = false, onMatch }: UseTypeaheadParams = {}) {
27
+ const queryRef = useRef('');
28
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+
30
+ const resetQuery = useCallback(() => {
31
+ queryRef.current = '';
32
+ if (timeoutRef.current != null) {
33
+ clearTimeout(timeoutRef.current);
34
+ timeoutRef.current = null;
35
+ }
36
+ }, []);
37
+
38
+ useEffect(() => resetQuery, [resetQuery]);
39
+
40
+ return useCallback(
41
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
42
+ if (disabled || !isTypingKey(event, queryRef.current)) {
43
+ if (resetsTypeaheadQuery(event.key)) {
44
+ resetQuery();
45
+ }
46
+ return;
47
+ }
48
+
49
+ event.preventDefault();
50
+ event.stopPropagation();
51
+
52
+ queryRef.current += event.key;
53
+ const needle = searchableString(queryRef.current);
54
+
55
+ if (timeoutRef.current != null) {
56
+ clearTimeout(timeoutRef.current);
57
+ }
58
+ timeoutRef.current = setTimeout(resetQuery, TYPEAHEAD_QUERY_RESET_TIMEOUT_MS);
59
+
60
+ const options = Array.from(
61
+ event.currentTarget.querySelectorAll<HTMLElement>(
62
+ '[role="option"]:not([aria-disabled="true"])',
63
+ ),
64
+ );
65
+ const optionLabels = options.map((option) => searchableString(option.textContent ?? ''));
66
+ const matchingIndex = optionLabels.findIndex((label) => label.startsWith(needle));
67
+ const closestIndex =
68
+ matchingIndex === -1
69
+ ? optionLabels.findIndex((label) => label.includes(needle))
70
+ : matchingIndex;
71
+
72
+ if (closestIndex === -1) {
73
+ return;
74
+ }
75
+
76
+ options[closestIndex].focus({ preventScroll: true });
77
+ event.currentTarget.focus({ preventScroll: true });
78
+ onMatch?.();
79
+ },
80
+ [disabled, onMatch, resetQuery],
81
+ );
82
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-webpack5';
2
- import { withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
2
+ import { useRtlDiffI18n, withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
3
3
  import Info from '../../Info';
4
4
  import Label from '..';
5
5
 
@@ -12,25 +12,38 @@ export default {
12
12
  type Story = StoryObj<typeof Label>;
13
13
 
14
14
  export const RTLDiff: Story = {
15
- render: () => (
16
- <>
17
- <Label>Plain label text</Label>
18
- <Label>
19
- <Label.Optional>Label with optional marker</Label.Optional>
20
- </Label>
21
- <Label>
22
- <div>
23
- Label with info icon{' '}
24
- <Info content="Helpful tooltip content" aria-label="More information" />
25
- </div>
26
- </Label>
27
- <Label>
28
- <Label.Optional>
29
- Label with optional and info icon{' '}
30
- <Info content="Helpful tooltip content" aria-label="More information" />
31
- </Label.Optional>
32
- </Label>
33
- </>
34
- ),
35
- ...withRTLDiff(),
15
+ render: () => {
16
+ const i18n = useRtlDiffI18n();
17
+ return (
18
+ <>
19
+ <Label>{i18n.plain}</Label>
20
+ <Label>
21
+ <Label.Optional>{i18n.optional}</Label.Optional>
22
+ </Label>
23
+ <Label>
24
+ <div>
25
+ {i18n.withInfo} <Info content={i18n.tooltip} aria-label={i18n.moreInfo} />
26
+ </div>
27
+ </Label>
28
+ <Label>
29
+ <Label.Optional>
30
+ {i18n.optionalWithInfo} <Info content={i18n.tooltip} aria-label={i18n.moreInfo} />
31
+ </Label.Optional>
32
+ </Label>
33
+ </>
34
+ );
35
+ },
36
+ ...withRTLDiff({
37
+ i18n: {
38
+ plain: { ltr: 'Plain label text', rtl: 'نص التسمية العادي' },
39
+ optional: { ltr: 'Label with optional marker', rtl: 'تسمية مع علامة اختيارية' },
40
+ withInfo: { ltr: 'Label with info icon', rtl: 'تسمية مع أيقونة معلومات' },
41
+ optionalWithInfo: {
42
+ ltr: 'Label with optional and info icon',
43
+ rtl: 'تسمية مع اختيارية وأيقونة معلومات',
44
+ },
45
+ tooltip: { ltr: 'Helpful tooltip content', rtl: 'محتوى تلميح مفيد' },
46
+ moreInfo: { ltr: 'More information', rtl: 'مزيد من المعلومات' },
47
+ },
48
+ }),
36
49
  };
@@ -14,16 +14,6 @@ export default {
14
14
 
15
15
  type Story = StoryObj<typeof Popover>;
16
16
 
17
- const iframeDiff = withRTLDiff({ renderAs: 'iframe' });
18
-
19
- const containerStyle: React.CSSProperties = {
20
- display: 'flex',
21
- justifyContent: 'center',
22
- alignItems: 'center',
23
- minHeight: '24rem',
24
- width: '100%',
25
- };
26
-
27
17
  const clickTrigger = async ({ canvasElement }: { canvasElement: HTMLElement }) => {
28
18
  const canvas = within(canvasElement);
29
19
  const trigger = canvas.queryByRole('button');
@@ -33,64 +23,64 @@ const clickTrigger = async ({ canvasElement }: { canvasElement: HTMLElement }) =
33
23
 
34
24
  export const PlacementRight: Story = {
35
25
  render: () => (
36
- <div style={containerStyle}>
37
- <Popover
38
- preferredPlacement={Position.RIGHT}
39
- title="Guaranteed rate"
40
- content="You'll get this rate as long as we receive your 10 EUR within the next 51 hours."
41
- >
42
- <Button v2>Placement right</Button>
43
- </Popover>
44
- </div>
26
+ <Popover
27
+ preferredPlacement={Position.RIGHT}
28
+ title="Guaranteed rate"
29
+ content="You'll get this rate as long as we receive your 10 EUR within the next 51 hours."
30
+ >
31
+ <Button size="sm" v2>
32
+ right
33
+ </Button>
34
+ </Popover>
45
35
  ),
46
36
  play: clickTrigger,
47
- ...iframeDiff,
37
+ ...withRTLDiff({ renderAs: 'iframe', hAlign: 'left', vAlign: 'center' }),
48
38
  };
49
39
 
50
40
  export const PlacementLeft: Story = {
51
41
  render: () => (
52
- <div style={containerStyle}>
53
- <Popover
54
- preferredPlacement={Position.LEFT}
55
- title="Guaranteed rate"
56
- content="You'll get this rate as long as we receive your 10 EUR within the next 51 hours."
57
- >
58
- <Button v2>Placement left</Button>
59
- </Popover>
60
- </div>
42
+ <Popover
43
+ preferredPlacement={Position.LEFT}
44
+ title="Guaranteed rate"
45
+ content="You'll get this rate as long as we receive your 10 EUR within the next 51 hours."
46
+ >
47
+ <Button size="sm" v2>
48
+ left
49
+ </Button>
50
+ </Popover>
61
51
  ),
62
52
  play: clickTrigger,
63
- ...iframeDiff,
53
+ ...withRTLDiff({ renderAs: 'iframe', hAlign: 'right', vAlign: 'center' }),
64
54
  };
65
55
 
66
56
  export const PlacementTop: Story = {
67
57
  render: () => (
68
- <div style={containerStyle}>
69
- <Popover
70
- preferredPlacement={Position.TOP}
71
- title="Guaranteed rate"
72
- content="You'll get this rate as long as we receive your 10 EUR within the next 51 hours."
73
- >
74
- <Button v2>Placement top</Button>
75
- </Popover>
76
- </div>
58
+ <Popover
59
+ preferredPlacement={Position.TOP}
60
+ title="Guaranteed rate"
61
+ content="You'll get this rate as long as we receive your 10 EUR within the next 51 hours."
62
+ >
63
+ <Button size="sm" v2>
64
+ top
65
+ </Button>
66
+ </Popover>
77
67
  ),
78
68
  play: clickTrigger,
79
- ...iframeDiff,
69
+ ...withRTLDiff({ renderAs: 'iframe', vAlign: 'bottom', hAlign: 'center' }),
80
70
  };
81
71
 
82
72
  export const PlacementBottom: Story = {
83
73
  render: () => (
84
- <div style={containerStyle}>
85
- <Popover
86
- preferredPlacement={Position.BOTTOM}
87
- title="Guaranteed rate"
88
- content="You'll get this rate as long as we receive your 10 EUR within the next 51 hours."
89
- >
90
- <Button v2>Placement bottom</Button>
91
- </Popover>
92
- </div>
74
+ <Popover
75
+ preferredPlacement={Position.BOTTOM}
76
+ title="Guaranteed rate"
77
+ content="You'll get this rate as long as we receive your 10 EUR within the next 51 hours."
78
+ >
79
+ <Button size="sm" v2>
80
+ bottom
81
+ </Button>
82
+ </Popover>
93
83
  ),
94
84
  play: clickTrigger,
95
- ...iframeDiff,
85
+ ...withRTLDiff({ renderAs: 'iframe', hAlign: 'center' }),
96
86
  };
@@ -35,7 +35,8 @@
35
35
  background: var(--color-background-screen);
36
36
  border-radius: 24px;
37
37
  border-radius: var(--size-24);
38
- transition: left 300ms;
38
+ transition: left 300ms,
39
+ right 300ms;
39
40
  }
40
41
  .segmented-control__segments--no-animate::after {
41
42
  transition: none !important;
@@ -12,7 +12,7 @@
12
12
  justify-content: center;
13
13
  align-items: center;
14
14
  background: var(--color-background-neutral);
15
- border-radius: var(--size-24);
15
+ border-radius: var(--size-24);
16
16
  transition: outline 300ms;
17
17
  outline: 2px solid transparent;
18
18
  }
@@ -31,7 +31,9 @@
31
31
  z-index: 0;
32
32
  background: var(--color-background-screen);
33
33
  border-radius: var(--size-24);
34
- transition: left 300ms;
34
+ transition:
35
+ left 300ms,
36
+ right 300ms;
35
37
  }
36
38
 
37
39
  .segmented-control__segments--no-animate::after {
@@ -3,6 +3,7 @@ import { createRef, useEffect, useRef, useState } from 'react';
3
3
 
4
4
  import Body from '../Body';
5
5
  import { Typography } from '../common';
6
+ import { useDirection } from '../common/hooks';
6
7
 
7
8
  type SegmentBase = { id: string; label: string; value: string };
8
9
 
@@ -40,6 +41,7 @@ const SegmentedControl = ({
40
41
  }: SegmentedControlProps) => {
41
42
  const isMountedRef = useRef(false);
42
43
  const [animate, setAnimate] = useState(false);
44
+ const { isRTL } = useDirection();
43
45
 
44
46
  const segmentsRef = useRef<HTMLDivElement>(null);
45
47
 
@@ -62,8 +64,12 @@ const SegmentedControl = ({
62
64
  // This is so we can animate the highlight to the selected segment
63
65
  if (selectedSegmentRef?.current && segmentsRef.current) {
64
66
  const { style } = segmentsRef.current;
65
- style.setProperty('--segment-highlight-width', `${selectedSegmentRef.current.offsetWidth}px`);
66
- style.setProperty('--segment-highlight-x', `${selectedSegmentRef.current.offsetLeft}px`);
67
+ const segment = selectedSegmentRef.current;
68
+ const inlineOffset = isRTL
69
+ ? segmentsRef.current.offsetWidth - segment.offsetLeft - segment.offsetWidth
70
+ : segment.offsetLeft;
71
+ style.setProperty('--segment-highlight-width', `${segment.offsetWidth}px`);
72
+ style.setProperty('--segment-highlight-x', `${inlineOffset}px`);
67
73
  }
68
74
  };
69
75
 
@@ -1,3 +1,4 @@
1
+ import { useState } from 'react';
1
2
  import type { Meta, StoryObj } from '@storybook/react-webpack5';
2
3
  import { withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
3
4
  import SegmentedControl from '../SegmentedControl';
@@ -22,24 +23,31 @@ const viewSegments = [
22
23
  { id: 'seg-gamma', label: 'Gamma', value: 'gamma', controls: 'panel-gamma' },
23
24
  ] as const;
24
25
 
25
- export const RTLDiff: Story = {
26
- render: () => (
26
+ function InteractiveSegmentedControl() {
27
+ const [inputValue, setInputValue] = useState('one');
28
+ const [viewValue, setViewValue] = useState('beta');
29
+
30
+ return (
27
31
  <>
28
32
  <SegmentedControl
29
33
  name="rtl-input"
30
34
  mode="input"
31
35
  segments={inputSegments}
32
- value="one"
33
- onChange={() => {}}
36
+ value={inputValue}
37
+ onChange={setInputValue}
34
38
  />
35
39
  <SegmentedControl
36
40
  name="rtl-view"
37
41
  mode="view"
38
42
  segments={viewSegments}
39
- value="alpha"
40
- onChange={() => {}}
43
+ value={viewValue}
44
+ onChange={setViewValue}
41
45
  />
42
46
  </>
43
- ),
44
- ...withRTLDiff(),
47
+ );
48
+ }
49
+
50
+ export const RTLDiff: Story = {
51
+ render: () => <InteractiveSegmentedControl />,
52
+ ...withRTLDiff({ width: 'stretch', renderAs: 'iframe' }),
45
53
  };
@@ -42,9 +42,3 @@
42
42
  visibility: visible;
43
43
  visibility: initial;
44
44
  }
45
- .np-panel[data-popper-placement^="right"] > .np-panel__content.tooltip-inner .np-panel__arrow {
46
- inset-inline-start: -7px;
47
- }
48
- .np-panel[data-popper-placement^="left"] > .np-panel__content.tooltip-inner .np-panel__arrow {
49
- inset-inline-end: -7px;
50
- }
@@ -48,15 +48,3 @@
48
48
  }
49
49
  }
50
50
  }
51
-
52
- .np-panel[data-popper-placement^="right"] > .np-panel__content.tooltip-inner {
53
- .np-panel__arrow {
54
- left: -7px;
55
- }
56
- }
57
-
58
- .np-panel[data-popper-placement^="left"] > .np-panel__content.tooltip-inner {
59
- .np-panel__arrow {
60
- right: -7px;
61
- }
62
- }
@@ -1,6 +1,7 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { userEvent } from '@testing-library/user-event';
3
3
 
4
+ import { DirectionProvider } from '../Provider/Direction/DirectionProvider';
4
5
  import Tooltip from '.';
5
6
 
6
7
  describe('Tooltip Component', () => {
@@ -102,4 +103,54 @@ describe('Tooltip Component', () => {
102
103
  const tooltipElement = screen.getByRole('tooltip', { hidden: true });
103
104
  expect(tooltipElement).toBeVisible();
104
105
  });
106
+
107
+ describe('RTL position mirroring', () => {
108
+ it('mirrors position="left" to right in RTL', async () => {
109
+ render(
110
+ <DirectionProvider direction="rtl">
111
+ <Tooltip label="Test Tooltip" position="left">
112
+ <span>Hover me</span>
113
+ </Tooltip>
114
+ </DirectionProvider>,
115
+ );
116
+ const triggerElement = screen.getByText('Hover me');
117
+ await userEvent.hover(triggerElement);
118
+
119
+ const tooltip = screen.getByRole('tooltip', { hidden: true });
120
+ expect(tooltip).toHaveAttribute('data-popper-placement', 'right');
121
+ });
122
+
123
+ it('mirrors position="right" to left in RTL', async () => {
124
+ render(
125
+ <DirectionProvider direction="rtl">
126
+ <Tooltip label="Test Tooltip" position="right">
127
+ <span>Hover me</span>
128
+ </Tooltip>
129
+ </DirectionProvider>,
130
+ );
131
+ const triggerElement = screen.getByText('Hover me');
132
+ await userEvent.hover(triggerElement);
133
+
134
+ const tooltip = screen.getByRole('tooltip', { hidden: true });
135
+ expect(tooltip).toHaveAttribute('data-popper-placement', 'left');
136
+ });
137
+
138
+ it.each(['top', 'bottom'] as const)(
139
+ 'does not mirror position="%s" in RTL',
140
+ async (position) => {
141
+ render(
142
+ <DirectionProvider direction="rtl">
143
+ <Tooltip label="Test Tooltip" position={position}>
144
+ <span>Hover me</span>
145
+ </Tooltip>
146
+ </DirectionProvider>,
147
+ );
148
+ const triggerElement = screen.getByText('Hover me');
149
+ await userEvent.hover(triggerElement);
150
+
151
+ const tooltip = screen.getByRole('tooltip', { hidden: true });
152
+ expect(tooltip).toHaveAttribute('data-popper-placement', position);
153
+ },
154
+ );
155
+ });
105
156
  });
@@ -19,6 +19,7 @@ import {
19
19
  } from 'react';
20
20
 
21
21
  import { CommonProps, Position } from '../common';
22
+ import useDirection from '../common/hooks/useDirection';
22
23
  import {
23
24
  PositionBottom,
24
25
  PositionLeft,
@@ -42,12 +43,21 @@ const Tooltip = ({
42
43
  }: TooltipProps) => {
43
44
  const [open, setOpen] = useState(false);
44
45
  const arrowRef = useRef<HTMLDivElement | null>(null);
46
+ const { isRTL } = useDirection();
47
+
48
+ const resolvedPosition = isRTL
49
+ ? position === 'left'
50
+ ? 'right'
51
+ : position === 'right'
52
+ ? 'left'
53
+ : position
54
+ : position;
45
55
 
46
56
  const fallbackId = useId();
47
57
  const tooltipId = id ?? fallbackId;
48
58
 
49
59
  const { refs, floatingStyles, placement, middlewareData } = useFloating({
50
- placement: position,
60
+ placement: resolvedPosition,
51
61
  middleware: [
52
62
  offset(16),
53
63
  flip({ fallbackPlacements: [Position.TOP] }),