@transferwise/components 46.111.0 → 46.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/build/avatarLayout/AvatarLayout.js.map +1 -1
  2. package/build/avatarLayout/AvatarLayout.mjs.map +1 -1
  3. package/build/avatarView/AvatarView.js +27 -29
  4. package/build/avatarView/AvatarView.js.map +1 -1
  5. package/build/avatarView/AvatarView.mjs +27 -29
  6. package/build/avatarView/AvatarView.mjs.map +1 -1
  7. package/build/avatarView/{NotificationDot.js → Dot.js} +14 -12
  8. package/build/avatarView/Dot.js.map +1 -0
  9. package/build/avatarView/{NotificationDot.mjs → Dot.mjs} +14 -12
  10. package/build/avatarView/Dot.mjs.map +1 -0
  11. package/build/badge/BadgeAssets.js.map +1 -1
  12. package/build/badge/BadgeAssets.mjs.map +1 -1
  13. package/build/common/panel/Panel.js +1 -0
  14. package/build/common/panel/Panel.js.map +1 -1
  15. package/build/common/panel/Panel.mjs +1 -0
  16. package/build/common/panel/Panel.mjs.map +1 -1
  17. package/build/common/responsivePanel/ResponsivePanel.js +6 -1
  18. package/build/common/responsivePanel/ResponsivePanel.js.map +1 -1
  19. package/build/common/responsivePanel/ResponsivePanel.mjs +6 -1
  20. package/build/common/responsivePanel/ResponsivePanel.mjs.map +1 -1
  21. package/build/dateInput/DateInput.js +46 -24
  22. package/build/dateInput/DateInput.js.map +1 -1
  23. package/build/dateInput/DateInput.mjs +48 -26
  24. package/build/dateInput/DateInput.mjs.map +1 -1
  25. package/build/dateLookup/DateLookup.js +5 -2
  26. package/build/dateLookup/DateLookup.js.map +1 -1
  27. package/build/dateLookup/DateLookup.mjs +5 -2
  28. package/build/dateLookup/DateLookup.mjs.map +1 -1
  29. package/build/dateLookup/dateTrigger/DateTrigger.js +2 -0
  30. package/build/dateLookup/dateTrigger/DateTrigger.js.map +1 -1
  31. package/build/dateLookup/dateTrigger/DateTrigger.mjs +2 -0
  32. package/build/dateLookup/dateTrigger/DateTrigger.mjs.map +1 -1
  33. package/build/field/Field.js +7 -2
  34. package/build/field/Field.js.map +1 -1
  35. package/build/field/Field.mjs +13 -8
  36. package/build/field/Field.mjs.map +1 -1
  37. package/build/inputs/InputGroup.js +1 -1
  38. package/build/inputs/InputGroup.js.map +1 -1
  39. package/build/inputs/InputGroup.mjs +2 -2
  40. package/build/inputs/InputGroup.mjs.map +1 -1
  41. package/build/inputs/SelectInput.js +54 -5
  42. package/build/inputs/SelectInput.js.map +1 -1
  43. package/build/inputs/SelectInput.mjs +54 -5
  44. package/build/inputs/SelectInput.mjs.map +1 -1
  45. package/build/inputs/contexts.js +8 -4
  46. package/build/inputs/contexts.js.map +1 -1
  47. package/build/inputs/contexts.mjs +7 -4
  48. package/build/inputs/contexts.mjs.map +1 -1
  49. package/build/label/Label.js +14 -8
  50. package/build/label/Label.js.map +1 -1
  51. package/build/label/Label.mjs +14 -8
  52. package/build/label/Label.mjs.map +1 -1
  53. package/build/listItem/Prompt/ListItemPrompt.js +1 -1
  54. package/build/listItem/Prompt/ListItemPrompt.js.map +1 -1
  55. package/build/listItem/Prompt/ListItemPrompt.mjs +1 -1
  56. package/build/listItem/Prompt/ListItemPrompt.mjs.map +1 -1
  57. package/build/main.css +180 -164
  58. package/build/moneyInput/MoneyInput.js +6 -5
  59. package/build/moneyInput/MoneyInput.js.map +1 -1
  60. package/build/moneyInput/MoneyInput.mjs +6 -5
  61. package/build/moneyInput/MoneyInput.mjs.map +1 -1
  62. package/build/phoneNumberInput/PhoneNumberInput.js +25 -3
  63. package/build/phoneNumberInput/PhoneNumberInput.js.map +1 -1
  64. package/build/phoneNumberInput/PhoneNumberInput.mjs +27 -5
  65. package/build/phoneNumberInput/PhoneNumberInput.mjs.map +1 -1
  66. package/build/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.js +23 -23
  67. package/build/prompt/InlinePrompt/InlinePrompt.js.map +1 -0
  68. package/build/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.mjs +23 -23
  69. package/build/prompt/InlinePrompt/InlinePrompt.mjs.map +1 -0
  70. package/build/styles/avatarView/AvatarView.css +17 -11
  71. package/build/styles/avatarView/Dot.css +26 -0
  72. package/build/styles/inputs/Input.css +5 -0
  73. package/build/styles/inputs/TextArea.css +5 -0
  74. package/build/styles/listItem/ListItem.css +5 -153
  75. package/build/styles/listItem/Prompt/ListItemPrompt.css +0 -153
  76. package/build/styles/main.css +180 -164
  77. package/build/types/avatarLayout/AvatarLayout.d.ts +1 -1
  78. package/build/types/avatarLayout/AvatarLayout.d.ts.map +1 -1
  79. package/build/types/avatarView/AvatarView.d.ts +1 -2
  80. package/build/types/avatarView/AvatarView.d.ts.map +1 -1
  81. package/build/types/avatarView/Dot.d.ts +8 -0
  82. package/build/types/avatarView/Dot.d.ts.map +1 -0
  83. package/build/types/badge/BadgeAssets.d.ts +1 -1
  84. package/build/types/badge/BadgeAssets.d.ts.map +1 -1
  85. package/build/types/common/panel/Panel.d.ts +2 -0
  86. package/build/types/common/panel/Panel.d.ts.map +1 -1
  87. package/build/types/common/responsivePanel/ResponsivePanel.d.ts +1 -0
  88. package/build/types/common/responsivePanel/ResponsivePanel.d.ts.map +1 -1
  89. package/build/types/dateInput/DateInput.d.ts +2 -2
  90. package/build/types/dateInput/DateInput.d.ts.map +1 -1
  91. package/build/types/dateLookup/DateLookup.d.ts.map +1 -1
  92. package/build/types/dateLookup/dateTrigger/DateTrigger.d.ts +1 -0
  93. package/build/types/dateLookup/dateTrigger/DateTrigger.d.ts.map +1 -1
  94. package/build/types/field/Field.d.ts.map +1 -1
  95. package/build/types/inputs/InputGroup.d.ts.map +1 -1
  96. package/build/types/inputs/SelectInput.d.ts +27 -1
  97. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  98. package/build/types/inputs/contexts.d.ts +6 -1
  99. package/build/types/inputs/contexts.d.ts.map +1 -1
  100. package/build/types/label/Label.d.ts +5 -15
  101. package/build/types/label/Label.d.ts.map +1 -1
  102. package/build/types/listItem/Prompt/ListItemPrompt.d.ts +1 -1
  103. package/build/types/listItem/Prompt/ListItemPrompt.d.ts.map +1 -1
  104. package/build/types/moneyInput/MoneyInput.d.ts.map +1 -1
  105. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
  106. package/build/types/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.d.ts +1 -1
  107. package/build/types/prompt/InlinePrompt/InlinePrompt.d.ts.map +1 -0
  108. package/build/types/prompt/InlinePrompt/index.d.ts.map +1 -0
  109. package/build/types/prompt/index.d.ts +3 -0
  110. package/build/types/prompt/index.d.ts.map +1 -0
  111. package/package.json +1 -1
  112. package/src/DisabledComponents.story.tsx +156 -0
  113. package/src/avatarLayout/AvatarLayout.tsx +1 -1
  114. package/src/avatarView/AvatarView.css +17 -11
  115. package/src/avatarView/AvatarView.less +1 -1
  116. package/src/avatarView/AvatarView.story.tsx +92 -36
  117. package/src/avatarView/AvatarView.tsx +35 -30
  118. package/src/avatarView/Dot.css +26 -0
  119. package/src/avatarView/Dot.less +31 -0
  120. package/src/avatarView/Dot.tsx +42 -0
  121. package/src/badge/BadgeAssets.tsx +1 -1
  122. package/src/common/panel/Panel.tsx +2 -0
  123. package/src/common/responsivePanel/ResponsivePanel.tsx +7 -1
  124. package/src/dateInput/DateInput.spec.tsx +45 -7
  125. package/src/dateInput/DateInput.story.tsx +2 -0
  126. package/src/dateInput/DateInput.tsx +65 -30
  127. package/src/dateLookup/DateLookup.spec.tsx +16 -0
  128. package/src/dateLookup/DateLookup.tsx +6 -3
  129. package/src/dateLookup/dateTrigger/DateTrigger.tsx +3 -0
  130. package/src/field/Field.tsx +6 -5
  131. package/src/inputs/Input.css +5 -0
  132. package/src/inputs/InputGroup.tsx +3 -4
  133. package/src/inputs/SelectInput.story.tsx +101 -0
  134. package/src/inputs/SelectInput.tsx +113 -5
  135. package/src/inputs/TextArea.css +5 -0
  136. package/src/inputs/_common.less +5 -0
  137. package/src/inputs/contexts.tsx +12 -3
  138. package/src/label/Label.tsx +26 -20
  139. package/src/listItem/AvatarView/ListItemAvatarView.story.tsx +89 -25
  140. package/src/listItem/ListItem.css +5 -153
  141. package/src/listItem/ListItem.less +5 -0
  142. package/src/listItem/Prompt/ListItemPrompt.css +0 -153
  143. package/src/listItem/Prompt/ListItemPrompt.less +0 -2
  144. package/src/listItem/Prompt/ListItemPrompt.tsx +1 -1
  145. package/src/main.css +180 -164
  146. package/src/main.less +1 -0
  147. package/src/moneyInput/MoneyInput.spec.tsx +16 -1
  148. package/src/moneyInput/MoneyInput.tsx +7 -6
  149. package/src/phoneNumberInput/PhoneNumberInput.spec.tsx +32 -0
  150. package/src/phoneNumberInput/PhoneNumberInput.tsx +32 -11
  151. package/src/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.spec.tsx +2 -2
  152. package/src/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.tsx +4 -4
  153. package/src/prompt/index.ts +6 -0
  154. package/build/avatarView/NotificationDot.js.map +0 -1
  155. package/build/avatarView/NotificationDot.mjs.map +0 -1
  156. package/build/listItem/Prompt/InlinePrompt/InlinePrompt.js.map +0 -1
  157. package/build/listItem/Prompt/InlinePrompt/InlinePrompt.mjs.map +0 -1
  158. package/build/styles/avatarView/NotificationDot.css +0 -20
  159. package/build/types/avatarView/NotificationDot.d.ts +0 -8
  160. package/build/types/avatarView/NotificationDot.d.ts.map +0 -1
  161. package/build/types/listItem/Prompt/InlinePrompt/InlinePrompt.d.ts.map +0 -1
  162. package/build/types/listItem/Prompt/InlinePrompt/index.d.ts.map +0 -1
  163. package/src/avatarView/NotificationDot.css +0 -20
  164. package/src/avatarView/NotificationDot.less +0 -24
  165. package/src/avatarView/NotificationDot.tsx +0 -35
  166. /package/build/styles/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.css +0 -0
  167. /package/build/types/{listItem/Prompt → prompt}/InlinePrompt/index.d.ts +0 -0
  168. /package/src/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.css +0 -0
  169. /package/src/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.less +0 -0
  170. /package/src/{listItem/Prompt → prompt}/InlinePrompt/index.ts +0 -0
@@ -0,0 +1,42 @@
1
+ import { HTMLAttributes } from 'react';
2
+ import { Props as AvatarViewProps } from './AvatarView';
3
+ import { clsx } from 'clsx';
4
+
5
+ export type DotProps = Pick<HTMLAttributes<HTMLDivElement>, 'children'> & {
6
+ avatarSize?: AvatarViewProps['size'];
7
+ variant?: 'notification' | 'online';
8
+ };
9
+
10
+ /**
11
+ * Depending on avatar size, dot size and offset are different
12
+ */
13
+ const MAP_STYLE_CONFIG = {
14
+ 16: { size: 6, offset: 1 },
15
+ 24: { size: 8, offset: 2 },
16
+ 32: { size: 10, offset: 2 },
17
+ 40: { size: 10, offset: 2 },
18
+ 48: { size: 14, offset: 2 },
19
+ 56: { size: 16, offset: 3 },
20
+ 72: { size: 20, offset: 3 },
21
+ };
22
+
23
+ export default function Dot({ children, avatarSize = 48, variant = 'notification' }: DotProps) {
24
+ return (
25
+ <div
26
+ className="np-dot"
27
+ style={{
28
+ // @ts-expect-error CSS custom props allowed
29
+ '--np-dot-size': `${MAP_STYLE_CONFIG[avatarSize].size}px`,
30
+ '--np-dot-offset': `${MAP_STYLE_CONFIG[avatarSize].offset}px`,
31
+ }}
32
+ >
33
+ <div
34
+ className={clsx('np-dot-badge', {
35
+ 'np-dot-badge-notification': variant === 'notification',
36
+ 'np-dot-badge-online': variant === 'online',
37
+ })}
38
+ />
39
+ <div className="np-dot-mask">{children}</div>
40
+ </div>
41
+ );
42
+ }
@@ -9,7 +9,7 @@ export type Props = {
9
9
  flagCode?: string;
10
10
  imgSrc?: string;
11
11
  icon?: React.ReactNode;
12
- type?: 'action' | 'reference';
12
+ type?: 'action' | 'reference' | 'notification' | 'online';
13
13
  size?: 16 | 24;
14
14
  };
15
15
 
@@ -37,6 +37,7 @@ export type PanelProps = PropsWithChildren<{
37
37
  position?: PositionBottom | PositionLeft | PositionRight | PositionTop;
38
38
  anchorRef: MutableRefObject<Element | null>;
39
39
  anchorWidth?: boolean;
40
+ considerHeight?: boolean;
40
41
  }> &
41
42
  HTMLAttributes<HTMLDivElement>;
42
43
 
@@ -51,6 +52,7 @@ const Panel = forwardRef<HTMLDivElement, PanelProps>(function Panel(
51
52
  position = Position.BOTTOM,
52
53
  anchorRef,
53
54
  anchorWidth = false,
55
+ considerHeight = false,
54
56
  ...rest
55
57
  }: PanelProps,
56
58
  reference,
@@ -4,6 +4,7 @@ import { Position } from '..';
4
4
  import BottomSheet from '../bottomSheet';
5
5
  import { useLayout } from '../hooks';
6
6
  import Panel, { type PanelProps } from '../panel';
7
+ import { isServerSide } from '../domHelpers';
7
8
 
8
9
  const ResponsivePanel = forwardRef<HTMLDivElement, PanelProps>(function ResponsivePanel(
9
10
  {
@@ -17,12 +18,16 @@ const ResponsivePanel = forwardRef<HTMLDivElement, PanelProps>(function Responsi
17
18
  position = Position.BOTTOM,
18
19
  anchorWidth = false,
19
20
  'aria-label': ariaLabel,
21
+ considerHeight = false,
20
22
  'aria-labelledby': ariaLabelledBy,
21
23
  }: PanelProps,
22
24
  reference,
23
25
  ) {
24
26
  const { isMobile } = useLayout();
25
- if (isMobile) {
27
+ const SHORT_SCREEN = 500;
28
+ const isShortViewport = considerHeight && !isServerSide() && window.innerHeight < SHORT_SCREEN;
29
+
30
+ if (isMobile || isShortViewport) {
26
31
  return (
27
32
  <BottomSheet
28
33
  key="bottomSheet"
@@ -47,6 +52,7 @@ const ResponsivePanel = forwardRef<HTMLDivElement, PanelProps>(function Responsi
47
52
  anchorWidth={anchorWidth}
48
53
  anchorRef={anchorRef}
49
54
  aria-label={ariaLabel}
55
+ considerHeight={considerHeight}
50
56
  aria-labelledby={ariaLabelledBy}
51
57
  className={className}
52
58
  onClose={onClose}
@@ -200,15 +200,26 @@ describe('Date Input Component', () => {
200
200
  );
201
201
  const externalButton = screen.getByRole('button', { name: 'external button' });
202
202
 
203
- await userEvent.click(externalButton);
204
- await userEvent.click(screen.getByRole('textbox', { name: /day/i }));
205
- await userEvent.click(screen.getByRole('textbox', { name: /year/i }));
206
- await userEvent.click(externalButton);
203
+ const day = screen.getByRole('textbox', { name: /day/i });
204
+ const month = screen.getByRole('combobox', { name: /month/i });
205
+ const year = screen.getByRole('textbox', { name: /year/i });
207
206
 
208
- // 1 call is caused by the initial switch to the component,
209
- // as reflected in `should propagate if focusing from or
210
- // blurring to external component` test
207
+ await userEvent.click(externalButton);
208
+ await userEvent.click(day);
211
209
  expect(props.onFocus).toHaveBeenCalledTimes(1);
210
+ expect(props.onBlur).toHaveBeenCalledTimes(0);
211
+
212
+ await userEvent.click(month);
213
+ // selectinput really likes to refocus, apparently.
214
+ expect(props.onFocus).toHaveBeenCalledTimes(4);
215
+ expect(props.onBlur).toHaveBeenCalledTimes(0);
216
+
217
+ await userEvent.click(year);
218
+ expect(props.onFocus).toHaveBeenCalledTimes(5);
219
+ expect(props.onBlur).toHaveBeenCalledTimes(0);
220
+
221
+ await userEvent.click(externalButton);
222
+ expect(props.onFocus).toHaveBeenCalledTimes(5);
212
223
  expect(props.onBlur).toHaveBeenCalledTimes(1);
213
224
  });
214
225
  });
@@ -236,4 +247,31 @@ describe('Date Input Component', () => {
236
247
  );
237
248
  expect(screen.getAllByRole('group')[0]).toHaveAccessibleName(/^Date of birth/);
238
249
  });
250
+
251
+ it('focuses day input when `Field` label is clicked', async () => {
252
+ const label = 'Date of birth';
253
+
254
+ render(
255
+ <Field label={label}>
256
+ <DateInput onChange={() => {}} />
257
+ </Field>,
258
+ );
259
+
260
+ const dayInput = screen.getByRole('textbox', { name: /day/i });
261
+ await userEvent.click(screen.getByText(label, { selector: 'label' })); // Have to use `getByText` due to the way `Field` handles group labelling
262
+ expect(dayInput).toHaveFocus();
263
+ });
264
+
265
+ it('focuses month input when `Field` label is clicked and day is not present', async () => {
266
+ const label = 'Date of birth';
267
+
268
+ render(
269
+ <Field label={label}>
270
+ <DateInput mode="month-year" onChange={() => {}} />
271
+ </Field>,
272
+ );
273
+ const monthTrigger = screen.getByRole('combobox');
274
+ await userEvent.click(screen.getByText(label, { selector: 'label' })); // Have to use `getByText` due to the way `Field` handles group labelling
275
+ expect(monthTrigger).toHaveAttribute('aria-expanded', 'true');
276
+ });
239
277
  });
@@ -15,5 +15,7 @@ type Story = StoryObj<typeof meta>;
15
15
  export const Basic: Story = {
16
16
  args: {
17
17
  onChange: fn(),
18
+ onBlur: fn(),
19
+ onFocus: fn(),
18
20
  },
19
21
  } satisfies Story;
@@ -1,5 +1,5 @@
1
1
  import { clsx } from 'clsx';
2
- import { useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { useIntl } from 'react-intl';
4
4
 
5
5
  import Body from '../body';
@@ -19,7 +19,7 @@ import {
19
19
  Typography,
20
20
  } from '../common';
21
21
  import { MDY, YMD, getMonthNames, isDateValid, isMonthAndYearFormat } from '../common/dateUtils';
22
- import { useInputAttributes } from '../inputs/contexts';
22
+ import { useFieldLabelRef, useInputAttributes } from '../inputs/contexts';
23
23
  import messages from './DateInput.messages';
24
24
  import { convertToLocalMidnight } from './utils';
25
25
 
@@ -31,8 +31,8 @@ export interface DateInputProps {
31
31
  size?: SizeSmall | SizeMedium | SizeLarge;
32
32
  value?: Date | string;
33
33
  onChange: (value: string | null) => void;
34
- onFocus?: React.FocusEventHandler<HTMLDivElement>;
35
- onBlur?: React.FocusEventHandler<HTMLDivElement>;
34
+ onFocus?: React.FocusEventHandler<HTMLDivElement | HTMLFieldSetElement>;
35
+ onBlur?: React.FocusEventHandler<HTMLDivElement | HTMLFieldSetElement>;
36
36
  dayLabel?: string;
37
37
  dayAutoComplete?: string;
38
38
  monthLabel?: string;
@@ -49,6 +49,11 @@ export interface DateInputProps {
49
49
  selectProps?: Partial<SelectInputProps<number | null>>;
50
50
  }
51
51
 
52
+ /**
53
+ * To be passed to SelectInput's parentId prop for correct blur handling.
54
+ */
55
+ const DATE_INPUT_PARENT_ID = 'dateInput';
56
+
52
57
  const DateInput = ({
53
58
  'aria-labelledby': ariaLabelledByProp,
54
59
  'aria-label': ariaLabel,
@@ -70,6 +75,10 @@ const DateInput = ({
70
75
  selectProps = {},
71
76
  }: DateInputProps) => {
72
77
  const inputAttributes = useInputAttributes({ nonLabelable: true });
78
+ const fieldLabelRef = useFieldLabelRef();
79
+ const dayRef = useRef<HTMLInputElement>(null);
80
+ const monthRef = useRef<HTMLButtonElement>(null);
81
+ const yearRef = useRef<HTMLInputElement>(null);
73
82
  const id = idProp ?? inputAttributes.id;
74
83
  const ariaLabelledBy = ariaLabelledByProp ?? inputAttributes['aria-labelledby'];
75
84
 
@@ -116,6 +125,10 @@ const DateInput = ({
116
125
  );
117
126
  const monthNames = getMonthNames(locale, monthFormat);
118
127
 
128
+ const monthYearOnly = mode === DateMode.MONTH_YEAR;
129
+ const monthBeforeDay = MDY.has(locale);
130
+ const yearFirst = YMD.has(locale);
131
+
119
132
  dayLabel ||= formatMessage(messages.dayLabel);
120
133
  monthLabel ||= formatMessage(messages.monthLabel);
121
134
  yearLabel ||= formatMessage(messages.yearLabel);
@@ -125,6 +138,29 @@ const DateInput = ({
125
138
  year: placeholders?.year || formatMessage(messages.yearPlaceholder),
126
139
  };
127
140
 
141
+ useEffect(() => {
142
+ const labelRef = fieldLabelRef?.current;
143
+
144
+ if (labelRef) {
145
+ const handleLabelClick = () => {
146
+ // Not the best way to do this, but we're forced to recreate the native Label-click behavior
147
+ if (monthYearOnly || monthBeforeDay) {
148
+ monthRef.current?.click();
149
+ } else if (yearFirst) {
150
+ yearRef.current?.focus();
151
+ } else {
152
+ dayRef.current?.focus();
153
+ }
154
+ };
155
+
156
+ labelRef.addEventListener('click', handleLabelClick);
157
+
158
+ return () => {
159
+ labelRef?.removeEventListener('click', handleLabelClick);
160
+ };
161
+ }
162
+ }, [fieldLabelRef, id, monthBeforeDay, monthYearOnly, yearFirst]);
163
+
128
164
  const getDateAsString = (date: Date) => {
129
165
  if (!isDateValid(date)) {
130
166
  return '';
@@ -147,7 +183,9 @@ const DateInput = ({
147
183
  <label className="d-flex flex-column">
148
184
  <Body type={Typography.BODY_DEFAULT}>{monthLabel}</Body>
149
185
  <SelectInput
186
+ triggerRef={monthRef}
150
187
  id={`${id}:month`}
188
+ parentId={DATE_INPUT_PARENT_ID}
151
189
  name="month"
152
190
  disabled={disabled}
153
191
  placeholder={placeholders?.month}
@@ -239,8 +277,6 @@ const DateInput = ({
239
277
  }
240
278
  };
241
279
 
242
- const monthYearOnly = mode === DateMode.MONTH_YEAR;
243
-
244
280
  const monthWidth = clsx({
245
281
  'col-sm-8 tw-date--month': monthYearOnly,
246
282
  'col-sm-5 tw-date--month': !monthYearOnly,
@@ -255,8 +291,9 @@ const DateInput = ({
255
291
  <div className="col-sm-3 tw-date--day">
256
292
  <label>
257
293
  <Body type={Typography.BODY_DEFAULT}>{dayLabel}</Body>
258
- <div className={`input-group input-group-${size}`}>
294
+ <div className={`input-group input-group-${size} ${disabled ? 'disabled' : ''}`}>
259
295
  <Input
296
+ ref={dayRef}
260
297
  id={`${id}:day`}
261
298
  type="text"
262
299
  inputMode="numeric"
@@ -282,8 +319,9 @@ const DateInput = ({
282
319
  <div className="col-sm-4 tw-date--year">
283
320
  <label>
284
321
  <Body type={Typography.BODY_DEFAULT}>{yearLabel}</Body>
285
- <div className={`input-group input-group-${size}`}>
322
+ <div className={`input-group input-group-${size} ${disabled ? 'disabled' : ''}`}>
286
323
  <Input
324
+ ref={yearRef}
287
325
  id={`${id}:year`}
288
326
  type="text"
289
327
  inputMode="numeric"
@@ -303,17 +341,16 @@ const DateInput = ({
303
341
  </div>
304
342
  );
305
343
  };
306
- const monthBeforeDay = MDY.has(locale);
307
- const yearFirst = YMD.has(locale);
308
344
 
309
345
  return (
310
- <div
311
- className="tw-date"
312
- {...inputAttributes}
346
+ <fieldset
313
347
  id={id}
348
+ className="tw-date"
349
+ aria-describedby={inputAttributes['aria-describedby']}
350
+ aria-invalid={inputAttributes['aria-invalid']}
314
351
  aria-labelledby={ariaLabelledBy}
315
352
  aria-label={ariaLabel}
316
- role="group" // Add role attribute to indicate container for interactive elements
353
+ data-wds-dateinput=""
317
354
  onFocus={(event) =>
318
355
  shouldPropagateOnFocus(event) ? onFocus?.(event) : event.stopPropagation()
319
356
  }
@@ -357,7 +394,7 @@ const DateInput = ({
357
394
  );
358
395
  })()}
359
396
  </div>
360
- </div>
397
+ </fieldset>
361
398
  );
362
399
  };
363
400
 
@@ -366,27 +403,25 @@ function shouldPropagateOnFocus({
366
403
  target,
367
404
  relatedTarget,
368
405
  }: Pick<React.FocusEvent, 'target' | 'relatedTarget'>) {
369
- const targetParent = target.closest('.tw-date');
370
- const relatedParent = relatedTarget && relatedTarget.closest('.tw-date');
371
- return targetParent !== relatedParent;
406
+ const blurredElementParent = target.closest('[data-wds-dateinput]');
407
+ const focusedElementParent = relatedTarget?.closest('[data-wds-dateinput]');
408
+ return blurredElementParent !== focusedElementParent;
372
409
  }
373
410
 
374
- // Should only propagate if the relatedTarget or the activeElement is not part of this DateInput component.
411
+ // Should only propagate if the focus-gaining element is not part
412
+ // of this DateInput component or the (dropdown) of the month select.
375
413
  function shouldPropagateOnBlur({
376
414
  target,
377
415
  relatedTarget,
378
416
  }: Pick<React.FocusEvent, 'target' | 'relatedTarget'>) {
379
- const blurElementParent = target.closest('.tw-date');
380
- // Even though FocusEvent.relatedTarget is supported by IE
381
- // (https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent/relatedTarget)
382
- // "IE11 sets document.activeElement to the next focused element before the blur event is called."
383
- // (https://stackoverflow.com/a/49325196/986241)
384
- // Therefore if the relatedTarget is null, we try the document.activeElement,
385
- // which may contain the HTML element that is gaining focus
386
- const focusElement =
387
- relatedTarget || (document.activeElement !== target ? document.activeElement : null);
388
- const focusElementParent = focusElement && focusElement.closest('.tw-date');
389
- return blurElementParent !== focusElementParent;
417
+ const blurredElementParent = target.closest('[data-wds-dateinput]');
418
+ const focusedElementParent = relatedTarget?.closest('[data-wds-dateinput]');
419
+
420
+ return (
421
+ blurredElementParent !== focusedElementParent &&
422
+ !target?.closest(`[data-wds-parent="${DATE_INPUT_PARENT_ID}"]`) &&
423
+ !relatedTarget?.closest(`[data-wds-parent="${DATE_INPUT_PARENT_ID}"]`)
424
+ );
390
425
  }
391
426
 
392
427
  export default DateInput;
@@ -42,6 +42,22 @@ describe('DateLookup', () => {
42
42
  expect(button).toHaveAttribute('aria-haspopup');
43
43
  });
44
44
 
45
+ it('focuses trigger and opens panel when `Field` label is clicked', async () => {
46
+ const label = 'Date of birth';
47
+
48
+ render(
49
+ <Field label={label}>
50
+ <DateLookup value={initialValue} onChange={() => {}} />
51
+ </Field>,
52
+ );
53
+
54
+ const button = screen.getByRole('button', { name: /^Date of birth/ });
55
+ await user.click(screen.getByLabelText(label));
56
+
57
+ expect(button).toHaveAttribute('aria-expanded', 'true');
58
+ expect(screen.getByRole('button', { name: /next/iu })).toBeInTheDocument();
59
+ });
60
+
45
61
  it.each([' ', '{Enter}', '{ArrowDown}', '{ArrowUp}', '{ArrowRight}', '{ArrowLeft}'] as const)(
46
62
  "opens with '%s' and closes with '{Escape}'",
47
63
  async (text: string) => {
@@ -13,7 +13,7 @@ import {
13
13
  import { isWithinRange, moveToWithinRange, returnDateView } from '../common/dateUtils';
14
14
  import ResponsivePanel from '../common/responsivePanel';
15
15
  import { WithInputAttributesProps, withInputAttributes } from '../inputs/contexts';
16
- import { OverlayIdContext, OverlayIdProvider } from '../provider/overlay/OverlayIdProvider';
16
+ import { OverlayIdProvider } from '../provider/overlay/OverlayIdProvider';
17
17
  import DateTrigger from './dateTrigger';
18
18
  import DayCalendar from './dayCalendar';
19
19
  import { getStartOfDay } from './getStartOfDay';
@@ -319,13 +319,15 @@ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateL
319
319
  return (
320
320
  <div
321
321
  ref={this.element}
322
- {...inputAttributes}
323
- id={id}
322
+ aria-labelledby={id}
323
+ aria-invalid={inputAttributes?.['aria-invalid']}
324
+ aria-describedby={inputAttributes?.['aria-describedby']}
324
325
  className="input-group"
325
326
  onKeyDown={this.handleKeyDown}
326
327
  >
327
328
  <OverlayIdProvider open={open}>
328
329
  <DateTrigger
330
+ id={id}
329
331
  ariaLabelledBy={ariaLabelledBy}
330
332
  selectedDate={selectedDate}
331
333
  size={size}
@@ -341,6 +343,7 @@ class DateLookup extends PureComponent<DateLookupPropsWithInputAttributes, DateL
341
343
  open={open}
342
344
  className="tw-date-lookup-menu"
343
345
  position={Position.BOTTOM}
346
+ considerHeight
344
347
  onClose={this.discard}
345
348
  >
346
349
  {this.getCalendar()}
@@ -21,6 +21,7 @@ interface DateTriggerProps {
21
21
  disabled: boolean;
22
22
  onClick: () => void;
23
23
  onClear?: () => void;
24
+ id?: string;
24
25
  }
25
26
 
26
27
  const DateTrigger: React.FC<DateTriggerProps> = ({
@@ -28,6 +29,7 @@ const DateTrigger: React.FC<DateTriggerProps> = ({
28
29
  size = Size.MEDIUM,
29
30
  placeholder,
30
31
  label,
32
+ id,
31
33
  monthFormat,
32
34
  disabled,
33
35
  ariaLabelledBy,
@@ -42,6 +44,7 @@ const DateTrigger: React.FC<DateTriggerProps> = ({
42
44
  return (
43
45
  <>
44
46
  <button
47
+ id={id}
45
48
  aria-haspopup="dialog"
46
49
  aria-expanded={overlayId != null}
47
50
  aria-controls={overlayId}
@@ -1,10 +1,10 @@
1
1
  import { clsx } from 'clsx';
2
- import { useId } from 'react';
2
+ import { useId, useRef } from 'react';
3
3
 
4
4
  import { Sentiment } from '../common';
5
5
  import InlineAlert from '../inlineAlert/InlineAlert';
6
6
  import {
7
- FieldLabelIdContextProvider,
7
+ FieldLabelContextProvider,
8
8
  InputDescribedByProvider,
9
9
  InputIdContextProvider,
10
10
  InputInvalidProvider,
@@ -46,6 +46,7 @@ export const Field = ({
46
46
  children,
47
47
  ...props
48
48
  }: FieldProps) => {
49
+ const labelRef = useRef<HTMLLabelElement>(null);
49
50
  const sentiment = props.error ? Sentiment.NEGATIVE : propType;
50
51
  const message = propMessage || props.error;
51
52
  const hasError = sentiment === Sentiment.NEGATIVE;
@@ -74,7 +75,7 @@ export const Field = ({
74
75
  }
75
76
 
76
77
  return (
77
- <FieldLabelIdContextProvider value={labelId}>
78
+ <FieldLabelContextProvider value={{ id: labelId, ref: labelRef }}>
78
79
  <InputIdContextProvider value={inputId}>
79
80
  <InputDescribedByProvider value={ariaDescribedbyByIds()}>
80
81
  <InputInvalidProvider value={hasError}>
@@ -92,7 +93,7 @@ export const Field = ({
92
93
  >
93
94
  {label != null ? (
94
95
  <>
95
- <Label id={labelId} htmlFor={inputId}>
96
+ <Label ref={labelRef} id={labelId} htmlFor={inputId}>
96
97
  {required ? label : <Label.Optional>{label}</Label.Optional>}
97
98
  </Label>
98
99
  <Label.Description id={descriptionId}>{description}</Label.Description>
@@ -111,6 +112,6 @@ export const Field = ({
111
112
  </InputInvalidProvider>
112
113
  </InputDescribedByProvider>
113
114
  </InputIdContextProvider>
114
- </FieldLabelIdContextProvider>
115
+ </FieldLabelContextProvider>
115
116
  );
116
117
  };
@@ -18,6 +18,11 @@
18
18
  transition-duration: 300ms;
19
19
  /* TODO: Remove these overrides once `.form-control` isn’t used anymore */
20
20
  }
21
+ .disabled .np-form-control,
22
+ :disabled .np-form-control {
23
+ opacity: 1;
24
+ opacity: initial;
25
+ }
21
26
  .np-form-control:focus-visible {
22
27
  outline: none;
23
28
  }
@@ -4,11 +4,10 @@ import { createContext, useContext, useMemo, useRef, useState } from 'react';
4
4
  import { useResizeObserver } from '../common/hooks/useResizeObserver';
5
5
  import { cssValueWithUnit } from '../utilities/cssValueWithUnit';
6
6
  import {
7
- FieldLabelIdContextProvider,
7
+ FieldLabelContextProvider,
8
8
  InputDescribedByProvider,
9
9
  InputIdContextProvider,
10
10
  InputInvalidProvider,
11
- useInputAttributes,
12
11
  } from './contexts';
13
12
 
14
13
  type InputPaddingContextType = [
@@ -129,7 +128,7 @@ function InputAddon({
129
128
 
130
129
  return (
131
130
  /* Prevent nested controls from being labeled redundantly */
132
- <FieldLabelIdContextProvider value={undefined}>
131
+ <FieldLabelContextProvider value={undefined}>
133
132
  <InputIdContextProvider value={undefined}>
134
133
  <InputDescribedByProvider value={undefined}>
135
134
  <InputInvalidProvider value={undefined}>
@@ -153,6 +152,6 @@ function InputAddon({
153
152
  </InputInvalidProvider>
154
153
  </InputDescribedByProvider>
155
154
  </InputIdContextProvider>
156
- </FieldLabelIdContextProvider>
155
+ </FieldLabelContextProvider>
157
156
  );
158
157
  }
@@ -28,6 +28,13 @@ const meta = {
28
28
  onClose: fn() satisfies Mock,
29
29
  onOpen: fn() satisfies Mock,
30
30
  },
31
+ argTypes: {
32
+ parentId: {
33
+ table: {
34
+ category: 'WDS internal',
35
+ },
36
+ },
37
+ },
31
38
  parameters: { actions: { argTypesRegex: '' } },
32
39
  } satisfies Meta<typeof SelectInput>;
33
40
  export default meta;
@@ -509,3 +516,97 @@ export const WithinModal: Story<Currency> = {
509
516
  },
510
517
  ],
511
518
  };
519
+
520
+ interface Country {
521
+ code: string;
522
+ name: string;
523
+ }
524
+
525
+ const countries: Country[] = [
526
+ { code: 'US', name: 'United States' },
527
+ { code: 'GB', name: 'United Kingdom' },
528
+ { code: 'CA', name: 'Canada' },
529
+ { code: 'AU', name: 'Australia' },
530
+ { code: 'DE', name: 'Germany' },
531
+ { code: 'FR', name: 'France' },
532
+ { code: 'JP', name: 'Japan' },
533
+ { code: 'BR', name: 'Brazil' },
534
+ { code: 'IN', name: 'India' },
535
+ { code: 'CN', name: 'China' },
536
+ { code: 'IT', name: 'Italy' },
537
+ { code: 'ES', name: 'Spain' },
538
+ { code: 'NL', name: 'Netherlands' },
539
+ { code: 'CH', name: 'Switzerland' },
540
+ { code: 'SE', name: 'Sweden' },
541
+ ];
542
+
543
+ function countryOption(country: Country) {
544
+ return {
545
+ type: 'option',
546
+ value: country.code,
547
+ filterMatchers: [country.code, country.name],
548
+ } satisfies SelectInputItem;
549
+ }
550
+
551
+ export const WithAutocomplete: Story<string> = {
552
+ args: {
553
+ name: 'country',
554
+ autocomplete: 'country-name',
555
+ placeholder: 'Select your country',
556
+ items: countries.map(countryOption),
557
+ renderValue: (countryCode, withinTrigger) => {
558
+ const country = countries.find((c) => c.code === countryCode);
559
+ return (
560
+ <SelectInputOptionContent
561
+ title={withinTrigger ? countryCode : country?.name || countryCode}
562
+ note={withinTrigger ? undefined : countryCode}
563
+ icon={<Flag code={countryCode} intrinsicSize={24} />}
564
+ />
565
+ );
566
+ },
567
+ filterable: true,
568
+ filterPlaceholder: 'Type a country name',
569
+ size: 'lg',
570
+ },
571
+ render: function Render({ onChange, onClear, ...args }) {
572
+ const [selectedCountry, setSelectedCountry] = useState<string | undefined>(undefined);
573
+
574
+ return (
575
+ <div>
576
+ <form
577
+ method="post"
578
+ onSubmit={(e) => {
579
+ e.preventDefault();
580
+ console.log(
581
+ `Form submitted with country: ${selectedCountry}. This saves data for browser autocomplete!`,
582
+ );
583
+ }}
584
+ >
585
+ <div>
586
+ <label htmlFor="country-select" className="block text-sm font-medium mb-2">
587
+ Country Selection with Autocomplete:
588
+ </label>
589
+ <SelectInput
590
+ {...args}
591
+ id="country-select"
592
+ value={selectedCountry}
593
+ onChange={(country) => {
594
+ setSelectedCountry(country);
595
+ onChange?.(country);
596
+ console.log('Country selected via SelectInput:', country);
597
+ }}
598
+ onClear={() => {
599
+ setSelectedCountry(undefined);
600
+ onClear?.();
601
+ }}
602
+ />
603
+ </div>
604
+
605
+ <Button type="submit" v2 className="m-t-2" data-testid="submit-btn">
606
+ Submit Form
607
+ </Button>
608
+ </form>
609
+ </div>
610
+ );
611
+ },
612
+ };