@transferwise/components 0.0.0-experimental-02eda23 → 0.0.0-experimental-32cf8db

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.
@@ -2,12 +2,14 @@ import { Listbox as ListboxBase } from '@headlessui/react';
2
2
  import { useId } from '@radix-ui/react-id';
3
3
  import { Check, ChevronDown, Cross } from '@transferwise/icons';
4
4
  import classNames from 'classnames';
5
- import { createContext, useState, useRef, forwardRef, useEffect, useMemo, useContext } from 'react';
5
+ import mergeProps from 'merge-props';
6
+ import { createContext, forwardRef, useContext, useEffect, useMemo, useRef, useState } from 'react';
6
7
  import { useIntl } from 'react-intl';
7
8
  import mergeRefs from 'react-merge-refs';
8
9
 
9
10
  import { useEffectEvent } from '../common/hooks/useEffectEvent';
10
11
  import { useScreenSize } from '../common/hooks/useScreenSize';
12
+ import { PolymorphicWithOverrides } from '../common/polymorphicWithOverrides/PolymorphicWithOverrides';
11
13
  import { Breakpoint } from '../common/propsValues/breakpoint';
12
14
  import messages from '../dateLookup/dateTrigger/DateTrigger.messages';
13
15
  import { wrapInFragment } from '../utilities/wrapInFragment';
@@ -15,7 +17,7 @@ import { wrapInFragment } from '../utilities/wrapInFragment';
15
17
  import { InputGroup } from './InputGroup';
16
18
  import { SearchInput } from './SearchInput';
17
19
  import { BottomSheet } from './_BottomSheet';
18
- import { ButtonInput, type ButtonInputProps } from './_ButtonInput';
20
+ import { ButtonInput } from './_ButtonInput';
19
21
  import { Popover } from './_Popover';
20
22
 
21
23
  function searchableString(value: string) {
@@ -37,8 +39,12 @@ function inferSearchableStrings(value: unknown) {
37
39
  }
38
40
 
39
41
  const SelectInputHasValueContext = createContext(false);
40
-
41
- const SelectInputOptionContentCompactContext = createContext(false);
42
+ const SelectInputTriggerButtonPropsContext = createContext<{
43
+ ref?: React.ForwardedRef<HTMLButtonElement>;
44
+ onClick?: () => void;
45
+ [key: string]: unknown;
46
+ }>({});
47
+ const SelectInputOptionContentWithinTriggerContext = createContext(false);
42
48
 
43
49
  interface SelectInputOptionItem<T = string> {
44
50
  type: 'option';
@@ -106,10 +112,17 @@ export interface SelectInputProps<T = string> {
106
112
  items: readonly SelectInputItem<NonNullable<T>>[];
107
113
  defaultValue?: T;
108
114
  value?: T;
109
- renderValue?: (value: NonNullable<T>, compact: boolean) => React.ReactNode;
110
115
  compareValues?:
111
116
  | (keyof NonNullable<T> & string)
112
117
  | ((a: T | undefined, b: T | undefined) => boolean);
118
+ renderValue?: (value: NonNullable<T>, withinTrigger: boolean) => React.ReactNode;
119
+ renderTrigger?: (args: {
120
+ content: React.ReactNode;
121
+ placeholderShown: boolean;
122
+ clear: (() => void) | undefined;
123
+ disabled: boolean;
124
+ className: string | undefined;
125
+ }) => React.ReactNode;
113
126
  filterable?: boolean;
114
127
  filterPlaceholder?: string;
115
128
  disabled?: boolean;
@@ -118,14 +131,66 @@ export interface SelectInputProps<T = string> {
118
131
  onClear?: () => void;
119
132
  }
120
133
 
121
- export function SelectInput<T>({
134
+ const defaultRenderTrigger = (({ content, placeholderShown, clear, disabled, className }) => (
135
+ <InputGroup
136
+ addonEnd={{
137
+ content: (
138
+ <span className={classNames('np-select-input-addon-container', disabled && 'disabled')}>
139
+ {clear != null && !placeholderShown ? (
140
+ <>
141
+ <SelectInputClearButton
142
+ onClick={(event) => {
143
+ event.preventDefault();
144
+ clear();
145
+ }}
146
+ />
147
+ <span className="np-select-input-addon-separator" />
148
+ </>
149
+ ) : null}
150
+
151
+ <span className="np-select-input-addon">
152
+ <ChevronDown size={16} />
153
+ </span>
154
+ </span>
155
+ ),
156
+ padding: 'sm',
157
+ }}
158
+ disabled={disabled}
159
+ className={className}
160
+ >
161
+ <SelectInputTriggerButton as={ButtonInput}>
162
+ {placeholderShown ? <span className="np-select-input-placeholder"> {content}</span> : content}
163
+ </SelectInputTriggerButton>
164
+ </InputGroup>
165
+ )) satisfies SelectInputProps['renderTrigger'];
166
+
167
+ interface SelectInputClearButtonProps
168
+ extends Pick<React.ComponentPropsWithoutRef<'button'>, 'className' | 'onClick'> {}
169
+
170
+ function SelectInputClearButton({ className, onClick }: SelectInputClearButtonProps) {
171
+ const intl = useIntl();
172
+
173
+ return (
174
+ <button
175
+ type="button"
176
+ aria-label={intl.formatMessage(messages.ariaLabel)}
177
+ className={classNames(className, 'np-select-input-addon np-select-input-addon--interactive')}
178
+ onClick={onClick}
179
+ >
180
+ <Cross size={16} />
181
+ </button>
182
+ );
183
+ }
184
+
185
+ export function SelectInput<T = string>({
122
186
  name,
123
187
  placeholder,
124
188
  items,
125
189
  defaultValue,
126
190
  value: controlledValue,
127
- renderValue = wrapInFragment,
128
191
  compareValues,
192
+ renderValue = wrapInFragment,
193
+ renderTrigger = defaultRenderTrigger,
129
194
  filterable,
130
195
  filterPlaceholder,
131
196
  disabled,
@@ -133,8 +198,6 @@ export function SelectInput<T>({
133
198
  onChange,
134
199
  onClear,
135
200
  }: SelectInputProps<T>) {
136
- const intl = useIntl();
137
-
138
201
  const [open, setOpen] = useState(false);
139
202
 
140
203
  const triggerRef = useRef<HTMLButtonElement>(null);
@@ -162,95 +225,85 @@ export function SelectInput<T>({
162
225
  >
163
226
  {({ disabled: uiDisabled, value }) => (
164
227
  <SelectInputHasValueContext.Provider value={value != null}>
165
- <InputGroup
166
- addonEnd={{
167
- content: (
168
- <span
169
- className={classNames(
170
- 'np-select-input-addon-container',
171
- uiDisabled && 'disabled',
172
- )}
173
- >
174
- {onClear != null && value != null ? (
175
- <>
176
- <button
177
- type="button"
178
- aria-label={intl.formatMessage(messages.ariaLabel)}
179
- disabled={uiDisabled}
180
- className="np-select-input-addon np-select-input-addon--interactive"
181
- onClick={(event) => {
182
- event.preventDefault();
228
+ <OptionsOverlay
229
+ open={open}
230
+ renderTrigger={({ ref, getInteractionProps }) => (
231
+ <SelectInputTriggerButtonPropsContext.Provider
232
+ // eslint-disable-next-line react/jsx-no-constructed-context-values
233
+ value={{
234
+ ref: mergeRefs([ref, triggerRef]),
235
+ ...mergeProps(
236
+ {
237
+ onClick: () => {
238
+ setOpen((prev) => !prev);
239
+ },
240
+ },
241
+ getInteractionProps(),
242
+ ),
243
+ }}
244
+ >
245
+ {renderTrigger({
246
+ content:
247
+ value != null ? (
248
+ <SelectInputOptionContentWithinTriggerContext.Provider value>
249
+ {renderValue(value, true)}
250
+ </SelectInputOptionContentWithinTriggerContext.Provider>
251
+ ) : (
252
+ placeholder
253
+ ),
254
+ placeholderShown: value == null,
255
+ clear:
256
+ onClear != null
257
+ ? () => {
183
258
  onClear();
184
259
  triggerRef.current?.focus({ preventScroll: true });
185
- }}
186
- >
187
- <Cross size={16} />
188
- </button>
189
- <span className="np-select-input-addon-separator" />
190
- </>
191
- ) : null}
192
-
193
- <span className="np-select-input-addon">
194
- <ChevronDown size={16} />
195
- </span>
196
- </span>
197
- ),
198
- padding: 'sm',
260
+ }
261
+ : undefined,
262
+ disabled: uiDisabled,
263
+ className: classNames(className, 'np-text-body-large'),
264
+ })}
265
+ </SelectInputTriggerButtonPropsContext.Provider>
266
+ )}
267
+ initialFocusRef={controllerRef}
268
+ padding="none"
269
+ onClose={() => {
270
+ setOpen(false);
199
271
  }}
200
- className={className}
201
272
  >
202
- <OptionsOverlay
203
- open={open}
204
- renderTrigger={({ ref, getInteractionProps }) => (
205
- <ListboxBase.Button
206
- ref={mergeRefs([ref, triggerRef])}
207
- as={SelectInputButton}
208
- overrides={getInteractionProps()}
209
- onClick={() => {
210
- setOpen((prev) => !prev);
211
- }}
212
- >
213
- {value != null ? (
214
- <SelectInputOptionContentCompactContext.Provider value>
215
- {renderValue(value, true)}
216
- </SelectInputOptionContentCompactContext.Provider>
217
- ) : (
218
- <span className="np-select-input-placeholder">{placeholder}</span>
219
- )}
220
- </ListboxBase.Button>
221
- )}
222
- initialFocusRef={controllerRef}
223
- padding="none"
224
- onClose={() => {
225
- setOpen(false);
226
- }}
227
- >
228
- <SelectInputOptions
229
- items={items}
230
- renderValue={renderValue}
231
- filterable={filterable}
232
- filterPlaceholder={filterPlaceholder}
233
- searchInputRef={searchInputRef}
234
- listboxRef={listboxRef}
235
- />
236
- </OptionsOverlay>
237
- </InputGroup>
273
+ <SelectInputOptions
274
+ items={items}
275
+ renderValue={renderValue}
276
+ filterable={filterable}
277
+ filterPlaceholder={filterPlaceholder}
278
+ searchInputRef={searchInputRef}
279
+ listboxRef={listboxRef}
280
+ />
281
+ </OptionsOverlay>
238
282
  </SelectInputHasValueContext.Provider>
239
283
  )}
240
284
  </ListboxBase>
241
285
  );
242
286
  }
243
287
 
244
- interface SelectInputButtonProps extends ButtonInputProps {
245
- overrides?: { [key: string]: unknown };
246
- }
288
+ export type SelectInputTriggerButtonProps<T extends React.ComponentType | 'button' = 'button'> = {
289
+ as?: T;
290
+ } & React.ComponentPropsWithoutRef<T>;
247
291
 
248
- const SelectInputButton = forwardRef(function SelectInputButton(
249
- { overrides, ...restProps }: SelectInputButtonProps,
250
- ref: React.ForwardedRef<HTMLButtonElement>,
251
- ) {
252
- return <ButtonInput ref={ref} {...restProps} {...overrides} />;
253
- });
292
+ export function SelectInputTriggerButton<T extends React.ComponentType | 'button' = 'button'>({
293
+ as = 'button' as T,
294
+ ...restProps
295
+ }: SelectInputTriggerButtonProps<T>) {
296
+ const { ref, onClick, ...interactionProps } = useContext(SelectInputTriggerButtonPropsContext);
297
+
298
+ return (
299
+ <ListboxBase.Button
300
+ ref={ref}
301
+ as={PolymorphicWithOverrides}
302
+ __overrides={{ as, ...interactionProps }}
303
+ {...mergeProps({ onClick }, restProps)}
304
+ />
305
+ );
306
+ }
254
307
 
255
308
  interface SelectInputOptionsContainerProps extends React.ComponentPropsWithRef<'div'> {
256
309
  onAriaActiveDescendantChange: (value: React.AriaAttributes['aria-activedescendant']) => void;
@@ -294,7 +347,7 @@ interface SelectInputOptionsProps<T = string>
294
347
  listboxRef: React.RefObject<HTMLDivElement>;
295
348
  }
296
349
 
297
- function SelectInputOptions<T>({
350
+ function SelectInputOptions<T = string>({
298
351
  items,
299
352
  renderValue = wrapInFragment,
300
353
  filterable,
@@ -395,7 +448,11 @@ interface SelectInputItemViewProps<
395
448
  needle: string | null;
396
449
  }
397
450
 
398
- function SelectInputItemView<T>({ item, renderValue, needle }: SelectInputItemViewProps<T>) {
451
+ function SelectInputItemView<T = string>({
452
+ item,
453
+ renderValue,
454
+ needle,
455
+ }: SelectInputItemViewProps<T>) {
399
456
  switch (item.type) {
400
457
  case 'option': {
401
458
  if (
@@ -418,7 +475,7 @@ function SelectInputItemView<T>({ item, renderValue, needle }: SelectInputItemVi
418
475
  }
419
476
  case 'separator': {
420
477
  if (needle == null) {
421
- return <hr className="np-select-input-separator-item" aria-hidden />;
478
+ return <hr className="np-select-input-separator-item" />;
422
479
  }
423
480
  break;
424
481
  }
@@ -429,7 +486,7 @@ function SelectInputItemView<T>({ item, renderValue, needle }: SelectInputItemVi
429
486
  interface SelectInputGroupItemViewProps<T = string>
430
487
  extends SelectInputItemViewProps<T, SelectInputGroupItem<T | undefined>> {}
431
488
 
432
- function SelectInputGroupItemView<T>({
489
+ function SelectInputGroupItemView<T = string>({
433
490
  item,
434
491
  renderValue,
435
492
  needle,
@@ -472,7 +529,7 @@ interface SelectInputOptionProps<T = string> {
472
529
  children?: React.ReactNode;
473
530
  }
474
531
 
475
- function SelectInputOption<T>({ value, disabled, children }: SelectInputOptionProps<T>) {
532
+ function SelectInputOption<T = string>({ value, disabled, children }: SelectInputOptionProps<T>) {
476
533
  const parentHasValue = useContext(SelectInputHasValueContext);
477
534
 
478
535
  // Avoid flash during exit transition
@@ -523,7 +580,7 @@ export function SelectInputOptionContent({
523
580
  description,
524
581
  icon,
525
582
  }: SelectInputOptionContentProps) {
526
- const compact = useContext(SelectInputOptionContentCompactContext);
583
+ const withinTrigger = useContext(SelectInputOptionContentWithinTriggerContext);
527
584
 
528
585
  return (
529
586
  <div className="np-select-input-option-content-container np-text-body-large">
@@ -531,7 +588,7 @@ export function SelectInputOptionContent({
531
588
  <div
532
589
  className={classNames(
533
590
  'np-select-input-option-content-icon',
534
- !compact && 'np-select-input-option-content-icon--not-compact',
591
+ !withinTrigger && 'np-select-input-option-content-icon--not-within-trigger',
535
592
  )}
536
593
  >
537
594
  {icon}
@@ -542,7 +599,7 @@ export function SelectInputOptionContent({
542
599
  <div
543
600
  className={classNames(
544
601
  'np-select-input-option-content-text-line-1',
545
- compact && 'np-select-input-option-content-text-compact',
602
+ withinTrigger && 'np-select-input-option-content-text-within-trigger',
546
603
  )}
547
604
  >
548
605
  <h4 className="d-inline np-text-body-large">{title}</h4>
@@ -557,7 +614,7 @@ export function SelectInputOptionContent({
557
614
  <div
558
615
  className={classNames(
559
616
  'np-select-input-option-content-text-secondary np-text-body-default',
560
- compact && 'np-select-input-option-content-text-compact',
617
+ withinTrigger && 'np-select-input-option-content-text-within-trigger',
561
618
  )}
562
619
  >
563
620
  {description}
@@ -3,7 +3,7 @@
3
3
  display: flex;
4
4
  max-height: var(--max-height);
5
5
  width: var(--width);
6
- min-width: min-content;
6
+ min-width: 20rem;
7
7
  flex-direction: column;
8
8
  border-radius: var(--radius-small);
9
9
  background-color: var(--color-background-elevated);