@transferwise/components 46.110.0 → 46.111.1

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 (135) hide show
  1. package/build/common/panel/Panel.js +1 -0
  2. package/build/common/panel/Panel.js.map +1 -1
  3. package/build/common/panel/Panel.mjs +1 -0
  4. package/build/common/panel/Panel.mjs.map +1 -1
  5. package/build/common/responsivePanel/ResponsivePanel.js +6 -1
  6. package/build/common/responsivePanel/ResponsivePanel.js.map +1 -1
  7. package/build/common/responsivePanel/ResponsivePanel.mjs +6 -1
  8. package/build/common/responsivePanel/ResponsivePanel.mjs.map +1 -1
  9. package/build/dateInput/DateInput.js +46 -24
  10. package/build/dateInput/DateInput.js.map +1 -1
  11. package/build/dateInput/DateInput.mjs +48 -26
  12. package/build/dateInput/DateInput.mjs.map +1 -1
  13. package/build/dateLookup/DateLookup.js +5 -2
  14. package/build/dateLookup/DateLookup.js.map +1 -1
  15. package/build/dateLookup/DateLookup.mjs +5 -2
  16. package/build/dateLookup/DateLookup.mjs.map +1 -1
  17. package/build/dateLookup/dateTrigger/DateTrigger.js +2 -0
  18. package/build/dateLookup/dateTrigger/DateTrigger.js.map +1 -1
  19. package/build/dateLookup/dateTrigger/DateTrigger.mjs +2 -0
  20. package/build/dateLookup/dateTrigger/DateTrigger.mjs.map +1 -1
  21. package/build/field/Field.js +7 -2
  22. package/build/field/Field.js.map +1 -1
  23. package/build/field/Field.mjs +13 -8
  24. package/build/field/Field.mjs.map +1 -1
  25. package/build/inputs/InputGroup.js +1 -1
  26. package/build/inputs/InputGroup.js.map +1 -1
  27. package/build/inputs/InputGroup.mjs +2 -2
  28. package/build/inputs/InputGroup.mjs.map +1 -1
  29. package/build/inputs/SelectInput.js +19 -5
  30. package/build/inputs/SelectInput.js.map +1 -1
  31. package/build/inputs/SelectInput.mjs +19 -5
  32. package/build/inputs/SelectInput.mjs.map +1 -1
  33. package/build/inputs/contexts.js +8 -4
  34. package/build/inputs/contexts.js.map +1 -1
  35. package/build/inputs/contexts.mjs +7 -4
  36. package/build/inputs/contexts.mjs.map +1 -1
  37. package/build/label/Label.js +14 -8
  38. package/build/label/Label.js.map +1 -1
  39. package/build/label/Label.mjs +14 -8
  40. package/build/label/Label.mjs.map +1 -1
  41. package/build/listItem/ListItem.js.map +1 -1
  42. package/build/listItem/ListItem.mjs.map +1 -1
  43. package/build/listItem/Prompt/ListItemPrompt.js +1 -1
  44. package/build/listItem/Prompt/ListItemPrompt.js.map +1 -1
  45. package/build/listItem/Prompt/ListItemPrompt.mjs +1 -1
  46. package/build/listItem/Prompt/ListItemPrompt.mjs.map +1 -1
  47. package/build/main.css +163 -153
  48. package/build/moneyInput/MoneyInput.js +6 -5
  49. package/build/moneyInput/MoneyInput.js.map +1 -1
  50. package/build/moneyInput/MoneyInput.mjs +6 -5
  51. package/build/moneyInput/MoneyInput.mjs.map +1 -1
  52. package/build/phoneNumberInput/PhoneNumberInput.js +25 -3
  53. package/build/phoneNumberInput/PhoneNumberInput.js.map +1 -1
  54. package/build/phoneNumberInput/PhoneNumberInput.mjs +27 -5
  55. package/build/phoneNumberInput/PhoneNumberInput.mjs.map +1 -1
  56. package/build/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.js +23 -23
  57. package/build/prompt/InlinePrompt/InlinePrompt.js.map +1 -0
  58. package/build/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.mjs +23 -23
  59. package/build/prompt/InlinePrompt/InlinePrompt.mjs.map +1 -0
  60. package/build/styles/inputs/Input.css +5 -0
  61. package/build/styles/inputs/TextArea.css +5 -0
  62. package/build/styles/listItem/ListItem.css +5 -153
  63. package/build/styles/listItem/Prompt/ListItemPrompt.css +0 -153
  64. package/build/styles/main.css +163 -153
  65. package/build/types/common/panel/Panel.d.ts +2 -0
  66. package/build/types/common/panel/Panel.d.ts.map +1 -1
  67. package/build/types/common/responsivePanel/ResponsivePanel.d.ts +1 -0
  68. package/build/types/common/responsivePanel/ResponsivePanel.d.ts.map +1 -1
  69. package/build/types/dateInput/DateInput.d.ts +2 -2
  70. package/build/types/dateInput/DateInput.d.ts.map +1 -1
  71. package/build/types/dateLookup/DateLookup.d.ts.map +1 -1
  72. package/build/types/dateLookup/dateTrigger/DateTrigger.d.ts +1 -0
  73. package/build/types/dateLookup/dateTrigger/DateTrigger.d.ts.map +1 -1
  74. package/build/types/field/Field.d.ts.map +1 -1
  75. package/build/types/inputs/InputGroup.d.ts.map +1 -1
  76. package/build/types/inputs/SelectInput.d.ts +9 -1
  77. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  78. package/build/types/inputs/contexts.d.ts +6 -1
  79. package/build/types/inputs/contexts.d.ts.map +1 -1
  80. package/build/types/label/Label.d.ts +5 -15
  81. package/build/types/label/Label.d.ts.map +1 -1
  82. package/build/types/listItem/ListItem.d.ts.map +1 -1
  83. package/build/types/listItem/Prompt/ListItemPrompt.d.ts +1 -1
  84. package/build/types/listItem/Prompt/ListItemPrompt.d.ts.map +1 -1
  85. package/build/types/moneyInput/MoneyInput.d.ts.map +1 -1
  86. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
  87. package/build/types/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.d.ts +1 -1
  88. package/build/types/prompt/InlinePrompt/InlinePrompt.d.ts.map +1 -0
  89. package/build/types/prompt/InlinePrompt/index.d.ts.map +1 -0
  90. package/build/types/prompt/index.d.ts +3 -0
  91. package/build/types/prompt/index.d.ts.map +1 -0
  92. package/package.json +5 -5
  93. package/src/DisabledComponents.story.tsx +156 -0
  94. package/src/common/panel/Panel.tsx +2 -0
  95. package/src/common/responsivePanel/ResponsivePanel.tsx +7 -1
  96. package/src/dateInput/DateInput.spec.tsx +45 -7
  97. package/src/dateInput/DateInput.story.tsx +2 -0
  98. package/src/dateInput/DateInput.tsx +65 -30
  99. package/src/dateLookup/DateLookup.spec.tsx +16 -0
  100. package/src/dateLookup/DateLookup.tsx +6 -3
  101. package/src/dateLookup/dateTrigger/DateTrigger.tsx +3 -0
  102. package/src/field/Field.tsx +6 -5
  103. package/src/inputs/Input.css +5 -0
  104. package/src/inputs/InputGroup.tsx +3 -4
  105. package/src/inputs/SelectInput.story.tsx +30 -10
  106. package/src/inputs/SelectInput.tsx +36 -6
  107. package/src/inputs/TextArea.css +5 -0
  108. package/src/inputs/_common.less +5 -0
  109. package/src/inputs/contexts.tsx +12 -3
  110. package/src/label/Label.tsx +26 -20
  111. package/src/listItem/ListItem.css +5 -153
  112. package/src/listItem/ListItem.less +5 -0
  113. package/src/listItem/ListItem.tsx +2 -1
  114. package/src/listItem/Prompt/ListItemPrompt.css +0 -153
  115. package/src/listItem/Prompt/ListItemPrompt.less +0 -2
  116. package/src/listItem/Prompt/ListItemPrompt.tsx +1 -1
  117. package/src/main.css +163 -153
  118. package/src/main.less +1 -0
  119. package/src/moneyInput/MoneyInput.spec.tsx +16 -1
  120. package/src/moneyInput/MoneyInput.tsx +7 -6
  121. package/src/neptune-css/NeptuneCSS.story.tsx +142 -0
  122. package/src/phoneNumberInput/PhoneNumberInput.spec.tsx +32 -0
  123. package/src/phoneNumberInput/PhoneNumberInput.tsx +32 -11
  124. package/src/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.spec.tsx +2 -2
  125. package/src/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.tsx +4 -4
  126. package/src/prompt/index.ts +6 -0
  127. package/build/listItem/Prompt/InlinePrompt/InlinePrompt.js.map +0 -1
  128. package/build/listItem/Prompt/InlinePrompt/InlinePrompt.mjs.map +0 -1
  129. package/build/types/listItem/Prompt/InlinePrompt/InlinePrompt.d.ts.map +0 -1
  130. package/build/types/listItem/Prompt/InlinePrompt/index.d.ts.map +0 -1
  131. /package/build/styles/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.css +0 -0
  132. /package/build/types/{listItem/Prompt → prompt}/InlinePrompt/index.d.ts +0 -0
  133. /package/src/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.css +0 -0
  134. /package/src/{listItem/Prompt → prompt}/InlinePrompt/InlinePrompt.less +0 -0
  135. /package/src/{listItem/Prompt → prompt}/InlinePrompt/index.ts +0 -0
@@ -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
  }
@@ -22,7 +22,19 @@ import {
22
22
  const meta = {
23
23
  title: 'Forms/SelectInput',
24
24
  component: SelectInput,
25
-
25
+ args: {
26
+ onFilterChange: fn() satisfies Mock,
27
+ onChange: fn() satisfies Mock,
28
+ onClose: fn() satisfies Mock,
29
+ onOpen: fn() satisfies Mock,
30
+ },
31
+ argTypes: {
32
+ parentId: {
33
+ table: {
34
+ category: 'WDS internal',
35
+ },
36
+ },
37
+ },
26
38
  parameters: { actions: { argTypesRegex: '' } },
27
39
  } satisfies Meta<typeof SelectInput>;
28
40
  export default meta;
@@ -47,10 +59,6 @@ export const Months: Story<Month | null> = {
47
59
  value: month,
48
60
  })),
49
61
  renderValue: (month) => <SelectInputOptionContent title={month.name} />,
50
- onFilterChange: fn() satisfies Mock,
51
- onChange: fn() satisfies Mock,
52
- onClose: fn() satisfies Mock,
53
- onClear: fn() satisfies Mock,
54
62
  },
55
63
  render: function Render({ onChange, onClear, ...args }) {
56
64
  const [selectedMonth, setSelectedMonth] = useState<Month | null>(null);
@@ -183,7 +191,6 @@ const CurrenciesArgs = {
183
191
  filterable: true,
184
192
  filterPlaceholder: 'Type a currency / country',
185
193
  size: 'lg',
186
- onChange: fn() satisfies Mock,
187
194
  } satisfies Story<Currency>['args'];
188
195
 
189
196
  export const Currencies: Story<Currency> = {
@@ -265,7 +272,7 @@ export const MultipleCurrencies: Story<Currency, true> = {
265
272
  />
266
273
  ),
267
274
  },
268
- play: async ({ canvasElement, step }) => {
275
+ play: async ({ canvasElement, step, args }) => {
269
276
  const canvas = within(canvasElement);
270
277
 
271
278
  await step('Open the combobox', async () => {
@@ -273,6 +280,7 @@ export const MultipleCurrencies: Story<Currency, true> = {
273
280
  await userEvent.click(triggerButton);
274
281
  await wait(500);
275
282
  await userEvent.unhover(triggerButton);
283
+ await expect(args.onOpen).toHaveBeenCalledOnce();
276
284
  });
277
285
 
278
286
  await step('Select EUR option', async () => {
@@ -349,6 +357,19 @@ export const WithSelectAll: Story<Currency, true> = {
349
357
  },
350
358
  };
351
359
 
360
+ export const WithClear: Story<Currency> = {
361
+ args: {
362
+ ...CurrenciesArgs,
363
+ onClear: fn() satisfies Mock,
364
+ },
365
+ play: async ({ step }) => {
366
+ await step('Has clear button', async () => {
367
+ const clearBtn = await screen.findByRole('button', { name: 'Clear' });
368
+ await expect(clearBtn).toBeInTheDocument();
369
+ });
370
+ },
371
+ };
372
+
352
373
  export const CustomTrigger: Story<Month> = {
353
374
  args: {
354
375
  placeholder: 'Month',
@@ -367,14 +388,14 @@ export const CustomTrigger: Story<Month> = {
367
388
  <ChevronDown size={16} />
368
389
  </SelectInputTriggerButton>
369
390
  ),
370
- onChange: fn() satisfies Mock,
371
391
  },
372
- play: async ({ canvasElement, step }) => {
392
+ play: async ({ canvasElement, step, args }) => {
373
393
  const canvas = within(canvasElement);
374
394
 
375
395
  await step('Open the combobox', async () => {
376
396
  const triggerButton = canvas.getByRole('combobox');
377
397
  await userEvent.click(triggerButton);
398
+ await expect(args.onOpen).toHaveBeenCalledOnce();
378
399
  });
379
400
  },
380
401
  };
@@ -414,7 +435,6 @@ export const Advanced: Story<Month> = {
414
435
  ),
415
436
  filterable: true,
416
437
  filterPlaceholder: 'Type a month’s name',
417
- onChange: fn() satisfies Mock,
418
438
  },
419
439
  play: async ({ canvasElement, step }) => {
420
440
  const canvas = within(canvasElement);
@@ -146,6 +146,11 @@ function filterSelectInputItems<T>(
146
146
 
147
147
  export interface SelectInputProps<T = string, M extends boolean = false> {
148
148
  id?: string;
149
+ /**
150
+ * Sets the `data-wds-parent` attribute on the listbox container, which is needed for complex components like DateInput to correctly manage event handling.
151
+ * @internal
152
+ */
153
+ parentId?: string;
149
154
  name?: string;
150
155
  multiple?: M;
151
156
  placeholder?: string;
@@ -176,8 +181,11 @@ export interface SelectInputProps<T = string, M extends boolean = false> {
176
181
  UNSAFE_triggerButtonProps?: WithInputAttributesProps['inputAttributes'] & {
177
182
  'aria-label'?: string;
178
183
  };
184
+ /** Ref to the select trigger button element. */
185
+ triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
179
186
  onFilterChange?: (args: { query: string; queryNormalized: string | null }) => void;
180
187
  onChange?: (value: M extends true ? T[] : T) => void;
188
+ onOpen?: () => void;
181
189
  onClose?: () => void;
182
190
  onClear?: () => void;
183
191
  }
@@ -245,6 +253,7 @@ const noop = () => {};
245
253
 
246
254
  export function SelectInput<T = string, M extends boolean = false>({
247
255
  id: idProp,
256
+ parentId,
248
257
  name,
249
258
  multiple,
250
259
  placeholder,
@@ -261,8 +270,10 @@ export function SelectInput<T = string, M extends boolean = false>({
261
270
  size = 'md',
262
271
  className,
263
272
  UNSAFE_triggerButtonProps,
273
+ triggerRef: externalTriggerRef,
264
274
  onFilterChange = noop,
265
275
  onChange,
276
+ onOpen,
266
277
  onClose,
267
278
  onClear,
268
279
  }: SelectInputProps<T, M>) {
@@ -273,15 +284,18 @@ export function SelectInput<T = string, M extends boolean = false>({
273
284
 
274
285
  const initialized = useRef(false);
275
286
  const handleClose = useEffectEvent(onClose ?? (() => {}));
287
+ const handleOpen = useEffectEvent(onOpen ?? (() => {}));
276
288
  useEffect(() => {
277
289
  if (initialized.current) {
278
- if (!open) {
290
+ if (open) {
291
+ handleOpen?.();
292
+ } else {
279
293
  handleClose?.();
280
294
  }
281
295
  } else {
282
296
  initialized.current = true;
283
297
  }
284
- }, [handleClose, open]);
298
+ }, [handleClose, handleOpen, open]);
285
299
 
286
300
  const [filterQuery, _setFilterQuery] = useState('');
287
301
  const deferredFilterQuery = useDeferredValue(filterQuery);
@@ -295,7 +309,7 @@ export function SelectInput<T = string, M extends boolean = false>({
295
309
  }
296
310
  });
297
311
 
298
- const triggerRef = useRef<HTMLButtonElement | null>(null);
312
+ const internalTriggerRef = useRef<HTMLButtonElement | null>(null);
299
313
 
300
314
  const screenSm = useScreenSize(Breakpoint.SMALL);
301
315
  const OptionsOverlay = screenSm ? Popover : BottomSheet;
@@ -363,7 +377,12 @@ export function SelectInput<T = string, M extends boolean = false>({
363
377
  value={{
364
378
  ref: (node) => {
365
379
  ref(node);
366
- triggerRef.current = node;
380
+ if (externalTriggerRef) {
381
+ // eslint-disable-next-line no-param-reassign
382
+ externalTriggerRef.current = node;
383
+ } else {
384
+ internalTriggerRef.current = node;
385
+ }
367
386
  },
368
387
  ...inputAttributes,
369
388
  ...UNSAFE_triggerButtonProps,
@@ -406,7 +425,9 @@ export function SelectInput<T = string, M extends boolean = false>({
406
425
  onClear != null
407
426
  ? () => {
408
427
  onClear();
409
- triggerRef.current?.focus({ preventScroll: true });
428
+ (externalTriggerRef?.current ?? internalTriggerRef.current)?.focus({
429
+ preventScroll: true,
430
+ });
410
431
  }
411
432
  : undefined,
412
433
  disabled: uiDisabled,
@@ -427,6 +448,7 @@ export function SelectInput<T = string, M extends boolean = false>({
427
448
  >
428
449
  <SelectInputOptions
429
450
  id={id ? `${id}Search` : undefined}
451
+ parentId={parentId}
430
452
  items={items}
431
453
  renderValue={renderValue}
432
454
  renderFooter={renderFooter}
@@ -529,7 +551,13 @@ const SelectInputOptionsContainer = forwardRef(function SelectInputOptionsContai
529
551
  interface SelectInputOptionsProps<T = string>
530
552
  extends Pick<
531
553
  SelectInputProps<T>,
532
- 'items' | 'renderValue' | 'renderFooter' | 'filterable' | 'filterPlaceholder' | 'id'
554
+ | 'items'
555
+ | 'renderValue'
556
+ | 'renderFooter'
557
+ | 'filterable'
558
+ | 'filterPlaceholder'
559
+ | 'id'
560
+ | 'parentId'
533
561
  > {
534
562
  searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
535
563
  listboxRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -541,6 +569,7 @@ interface SelectInputOptionsProps<T = string>
541
569
 
542
570
  function SelectInputOptions<T = string>({
543
571
  id,
572
+ parentId,
544
573
  items,
545
574
  renderValue = String,
546
575
  renderFooter,
@@ -691,6 +720,7 @@ function SelectInputOptions<T = string>({
691
720
  items.some((item) => item.type === 'group') &&
692
721
  'np-select-input-listbox-container--has-group',
693
722
  )}
723
+ data-wds-parent={parentId ?? undefined}
694
724
  >
695
725
  {resultsEmpty ? (
696
726
  <div id={statusId} className="np-select-input-options-status">
@@ -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
  }
@@ -15,6 +15,11 @@
15
15
  transition-timing-function: ease-in-out;
16
16
  transition-duration: 300ms;
17
17
 
18
+ .disabled &,
19
+ :disabled & {
20
+ opacity: unset;
21
+ }
22
+
18
23
  &:focus-visible {
19
24
  outline: none;
20
25
  }
@@ -1,7 +1,12 @@
1
1
  import { createContext, useContext } from 'react';
2
2
 
3
- const FieldLabelIdContext = createContext<string | undefined>(undefined);
4
- export const FieldLabelIdContextProvider = FieldLabelIdContext.Provider;
3
+ type FieldLabelContextType = {
4
+ id?: string;
5
+ ref?: React.RefObject<HTMLLabelElement>;
6
+ };
7
+
8
+ const FieldLabelContext = createContext<FieldLabelContextType | undefined>(undefined);
9
+ export const FieldLabelContextProvider = FieldLabelContext.Provider;
5
10
 
6
11
  const InputIdContext = createContext<string | undefined>(undefined);
7
12
  export const InputIdContextProvider = InputIdContext.Provider;
@@ -18,7 +23,7 @@ interface UseInputAttributesArgs {
18
23
  }
19
24
 
20
25
  export function useInputAttributes({ nonLabelable }: UseInputAttributesArgs = {}) {
21
- const labelId = useContext(FieldLabelIdContext);
26
+ const labelId = useContext(FieldLabelContext)?.id;
22
27
  return {
23
28
  id: useContext(InputIdContext),
24
29
  'aria-labelledby': nonLabelable ? labelId : undefined,
@@ -27,6 +32,10 @@ export function useInputAttributes({ nonLabelable }: UseInputAttributesArgs = {}
27
32
  } satisfies React.HTMLAttributes<HTMLElement>;
28
33
  }
29
34
 
35
+ export function useFieldLabelRef() {
36
+ return useContext(FieldLabelContext)?.ref;
37
+ }
38
+
30
39
  export interface WithInputAttributesProps {
31
40
  inputAttributes: ReturnType<typeof useInputAttributes>;
32
41
  }