@hyphen/hyphen-components 8.0.1 → 9.0.0-beta.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 (66) hide show
  1. package/dist/css/utilities.css +1 -1
  2. package/dist/css/utilities.css.map +1 -1
  3. package/dist/hyphen-components.cjs.development.js +353 -313
  4. package/dist/hyphen-components.cjs.development.js.map +1 -1
  5. package/dist/hyphen-components.cjs.production.min.js +2 -2
  6. package/dist/hyphen-components.cjs.production.min.js.map +1 -1
  7. package/dist/hyphen-components.esm.js +357 -314
  8. package/dist/hyphen-components.esm.js.map +1 -1
  9. package/dist/index.d.ts +222 -212
  10. package/package.json +6 -5
  11. package/src/components/Alert/Alert.tsx +5 -6
  12. package/src/components/Badge/Badge.test.tsx +12 -0
  13. package/src/components/Badge/Badge.tsx +52 -20
  14. package/src/components/Box/Box.mdx +1 -1
  15. package/src/components/Box/Box.stories.tsx +1 -1
  16. package/src/components/Box/Box.test.tsx +24 -1
  17. package/src/components/Box/Box.tsx +108 -82
  18. package/src/components/Card/Card.tsx +5 -1
  19. package/src/components/Card/components/CardFooter/CardFooter.tsx +0 -4
  20. package/src/components/Card/components/CardHeader/CardHeader.tsx +4 -1
  21. package/src/components/Card/components/CardSection/CardSection.tsx +4 -5
  22. package/src/components/CheckboxInput/CheckboxInput.test.tsx +17 -0
  23. package/src/components/CheckboxInput/CheckboxInput.tsx +15 -4
  24. package/src/components/CheckboxInput/components/Checkbox.test.tsx +36 -0
  25. package/src/components/CheckboxInput/components/Checkbox.tsx +8 -2
  26. package/src/components/Details/Details.test.tsx +8 -0
  27. package/src/components/Details/Details.tsx +5 -2
  28. package/src/components/Details/DetailsSummary.tsx +4 -1
  29. package/src/components/Drawer/Drawer.test.tsx +8 -0
  30. package/src/components/Drawer/Drawer.tsx +1 -1
  31. package/src/components/FormLabel/FormLabel.tsx +6 -3
  32. package/src/components/Formik/FormikCheckboxInput/FormikCheckboxInput.test.tsx +9 -0
  33. package/src/components/Formik/FormikCheckboxInput/FormikCheckboxInput.tsx +1 -0
  34. package/src/components/Formik/FormikSelectInput/FormikSelectInput.tsx +21 -11
  35. package/src/components/Formik/FormikSelectInputNative/FormikSelectInputNative.tsx +1 -1
  36. package/src/components/Formik/FormikTimePicker/FormikTimePicker.tsx +1 -1
  37. package/src/components/Heading/Heading.tsx +5 -6
  38. package/src/components/Icon/Icon.test.tsx +8 -0
  39. package/src/components/Icon/Icon.tsx +6 -7
  40. package/src/components/Modal/Modal.test.tsx +31 -1
  41. package/src/components/Modal/Modal.tsx +13 -7
  42. package/src/components/Popover/Popover.test.tsx +3 -4
  43. package/src/components/RadioGroup/RadioGroup.tsx +11 -6
  44. package/src/components/RadioGroup/RadioInput/RadioInputIcon.tsx +0 -4
  45. package/src/components/SelectInput/SelectInput.test.tsx +18 -0
  46. package/src/components/SelectInput/SelectInput.tsx +47 -27
  47. package/src/components/SelectInputInset/SelectInputInset.tsx +8 -9
  48. package/src/components/SelectInputNative/SelectInputNative.test.tsx +24 -0
  49. package/src/components/SelectInputNative/SelectInputNative.tsx +79 -14
  50. package/src/components/Switch/Switch.tsx +1 -1
  51. package/src/components/Table/Table.mdx +21 -1
  52. package/src/components/Table/Table.stories.tsx +5 -1
  53. package/src/components/Table/Table.tsx +7 -7
  54. package/src/components/Table/TableBody/TableBody.test.tsx +89 -1
  55. package/src/components/Table/TableBody/TableBody.tsx +23 -8
  56. package/src/components/Table/TableHead/TableHead.tsx +6 -6
  57. package/src/components/Table/TableHead/TableHeaderCell/TableHeaderCell.tsx +6 -6
  58. package/src/components/Table/common/TableRow/TableRow.test.tsx +17 -1
  59. package/src/components/Table/common/TableRow/TableRow.tsx +30 -10
  60. package/src/components/TextInput/TextInput.tsx +10 -8
  61. package/src/components/TextInputInset/TextInputInset.tsx +5 -6
  62. package/src/components/TextareaInput/TextareaInput.tsx +4 -5
  63. package/src/components/TextareaInputInset/TextareaInputInset.tsx +10 -17
  64. package/src/components/Toast/Toast.stories.tsx +5 -1
  65. package/src/lib/getColumnKeys.ts +5 -3
  66. package/src/types/index.ts +6 -7
@@ -7,7 +7,9 @@ import Select, {
7
7
  OptionsOrGroups,
8
8
  OnChangeValue,
9
9
  } from 'react-select';
10
- import AsyncCreatableSelect from 'react-select/async-creatable';
10
+ import AsyncCreatableSelect, {
11
+ AsyncCreatableProps,
12
+ } from 'react-select/async-creatable';
11
13
  import AsyncSelect from 'react-select/async';
12
14
  import CreatableSelect from 'react-select/creatable';
13
15
  import { ResponsiveProp } from '../../types';
@@ -42,7 +44,7 @@ export type TextInputSize =
42
44
  | 'lg'
43
45
  | ResponsiveProp<'sm' | 'md' | 'lg'>;
44
46
 
45
- export interface SelectInputProps {
47
+ interface SelectInputOwnProps {
46
48
  /**
47
49
  * The id attribute of the input.
48
50
  */
@@ -59,10 +61,6 @@ export interface SelectInputProps {
59
61
  * The value(s) of select.
60
62
  */
61
63
  value: any | any[]; // eslint-disable-line @typescript-eslint/no-explicit-any
62
- /**
63
- * Options for dropdown list.
64
- */
65
- options: SelectInputOptions | AsyncOptions;
66
64
  /**
67
65
  * Autofocus select input on render.
68
66
  */
@@ -84,10 +82,6 @@ export interface SelectInputProps {
84
82
  * Visually hide the label.
85
83
  */
86
84
  hideLabel?: boolean;
87
- /**
88
- * Load the input asynchronously.
89
- */
90
- isAsync?: boolean;
91
85
  /**
92
86
  * If the input value is clearable programmatically.
93
87
  */
@@ -137,18 +131,36 @@ export interface SelectInputProps {
137
131
  * The size of the text input.
138
132
  */
139
133
  size?: TextInputSize;
140
- /**
141
- * Additional props to be spread. These will be applied specifically to
142
- * the `react-select` component that powers the select. For full docs on
143
- * react-select props, [Click Here](https://react-select.com/props)
144
- */
145
- [x: string]: any; // eslint-disable-line
146
134
  }
147
135
 
136
+ type ReactSelectAsyncProps = AsyncCreatableProps<
137
+ SelectOption,
138
+ boolean,
139
+ SelectGroupOptions
140
+ >;
141
+
142
+ type ControlledReactSelectProps =
143
+ | 'aria-label'
144
+ | 'aria-labelledby'
145
+ | 'cacheOptions'
146
+ | 'classNamePrefix'
147
+ | 'components'
148
+ | 'defaultOptions'
149
+ | 'inputId'
150
+ | 'loadOptions'
151
+ | 'options'
152
+ | 'styles';
153
+
154
+ type SelectInputBaseProps = SelectInputOwnProps &
155
+ Omit<
156
+ ReactSelectAsyncProps,
157
+ keyof SelectInputOwnProps | ControlledReactSelectProps
158
+ >;
159
+
148
160
  type AsyncOptions = (
149
161
  inputValue: string
150
162
  ) => Promise<OptionsOrGroups<SelectOption, SelectGroupOptions>>;
151
- type AsyncSelectInputProps = SelectInputProps & {
163
+ export type AsyncSelectInputProps = SelectInputBaseProps & {
152
164
  /**
153
165
  * Load the input asynchronously.
154
166
  */
@@ -160,14 +172,14 @@ type AsyncSelectInputProps = SelectInputProps & {
160
172
  /**
161
173
  * If cacheOptions is passed, then the loaded data will be cached.
162
174
  */
163
- cacheOptions?: boolean;
175
+ cacheOptions?: ReactSelectAsyncProps['cacheOptions'];
164
176
  /**
165
177
  * The default set of options to show before the user starts searching.
166
178
  */
167
- defaultOptions?: boolean;
179
+ defaultOptions?: ReactSelectAsyncProps['defaultOptions'];
168
180
  };
169
181
 
170
- type SyncSelectInputProps = SelectInputProps & {
182
+ export type SyncSelectInputProps = SelectInputBaseProps & {
171
183
  /**
172
184
  * Load the input synchronously.
173
185
  */
@@ -176,10 +188,12 @@ type SyncSelectInputProps = SelectInputProps & {
176
188
  * Options for dropdown list.
177
189
  */
178
190
  options: SelectInputOptions;
191
+ cacheOptions?: never;
192
+ defaultOptions?: never;
179
193
  };
180
194
 
181
- export function SelectInput(props: AsyncSelectInputProps): JSX.Element;
182
- export function SelectInput(props: SyncSelectInputProps): JSX.Element;
195
+ export type SelectInputProps = AsyncSelectInputProps | SyncSelectInputProps;
196
+
183
197
  export function SelectInput(props: SelectInputProps): JSX.Element {
184
198
  const {
185
199
  id,
@@ -205,6 +219,8 @@ export function SelectInput(props: SelectInputProps): JSX.Element {
205
219
  placeholder = undefined,
206
220
  requiredIndicator = ' *',
207
221
  size = 'md',
222
+ cacheOptions,
223
+ defaultOptions,
208
224
  ...restProps
209
225
  } = props;
210
226
 
@@ -262,13 +278,17 @@ export function SelectInput(props: SelectInputProps): JSX.Element {
262
278
  isCreatable && isAsync
263
279
  ? AsyncCreatableSelect
264
280
  : isCreatable
265
- ? CreatableSelect
266
- : isAsync
267
- ? AsyncSelect
268
- : Select;
281
+ ? CreatableSelect
282
+ : isAsync
283
+ ? AsyncSelect
284
+ : Select;
269
285
 
270
286
  const selectOptions = isAsync
271
- ? ({ loadOptions: options } as { loadOptions: AsyncOptions })
287
+ ? {
288
+ loadOptions: options as AsyncOptions,
289
+ cacheOptions,
290
+ defaultOptions,
291
+ }
272
292
  : ({ options } as { options: SelectInputOptions });
273
293
 
274
294
  return (
@@ -6,7 +6,6 @@ import React, {
6
6
  FocusEvent,
7
7
  ForwardRefExoticComponent,
8
8
  ReactNode,
9
- HTMLProps,
10
9
  } from 'react';
11
10
  import classNames from 'classnames';
12
11
  import { ResponsiveProp } from '../../types';
@@ -36,11 +35,15 @@ export interface SelectInputInsetProps {
36
35
  /**
37
36
  * Callback function to call on change event.
38
37
  */
39
- onChange: (event: ChangeEvent<HTMLInputElement>) => void;
38
+ onChange: (event: ChangeEvent<HTMLSelectElement>) => void;
40
39
  /**
41
40
  * Value of selected option. Should match the value key in the option object.
42
41
  */
43
42
  value: string | number | null;
43
+ /**
44
+ * The select's autocomplete behavior.
45
+ */
46
+ autoComplete?: boolean | string;
44
47
  /**
45
48
  * Automatically focus the input when the page is loaded.
46
49
  */
@@ -61,7 +64,7 @@ export interface SelectInputInsetProps {
61
64
  /**
62
65
  * Props passed directly to the input element of the component
63
66
  */
64
- inputProps?: BoxProps & HTMLProps<HTMLInputElement>;
67
+ inputProps?: Omit<BoxProps<'select'>, 'as'>;
65
68
  /**
66
69
  * The input's disabled attribute
67
70
  */
@@ -77,7 +80,7 @@ export interface SelectInputInsetProps {
77
80
  /**
78
81
  * Callback function to call on blur event.
79
82
  */
80
- onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
83
+ onBlur?: (event: FocusEvent<HTMLSelectElement>) => void;
81
84
  /**
82
85
  * Callback function to call when input us cleared. When this is passed,
83
86
  * the input will display an icon on the right side, for triggering this callback.
@@ -88,7 +91,7 @@ export interface SelectInputInsetProps {
88
91
  /**
89
92
  * Callback function to call on focus event.
90
93
  */
91
- onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
94
+ onFocus?: (event: FocusEvent<HTMLSelectElement>) => void;
92
95
  /**
93
96
  * The input placeholder attribute.
94
97
  */
@@ -101,10 +104,6 @@ export interface SelectInputInsetProps {
101
104
  * The size of the text input.
102
105
  */
103
106
  size?: SelectInputInsetSize | ResponsiveProp<SelectInputInsetSize>;
104
- /**
105
- * Additional props to be spread to rendered element
106
- */
107
- [x: string]: any; // eslint-disable-line
108
107
  }
109
108
 
110
109
  export const SelectInputInset: ForwardRefExoticComponent<SelectInputInsetProps> =
@@ -102,6 +102,30 @@ describe('SelectInputNative', () => {
102
102
 
103
103
  expect(mockedHandleBlur).toBeCalledTimes(1);
104
104
  });
105
+
106
+ test('it forwards native select attributes to the select element', () => {
107
+ render(
108
+ <SelectInputNative
109
+ id="testId"
110
+ label="native attributes"
111
+ onChange={() => undefined}
112
+ options={selectOptions}
113
+ value={null}
114
+ form="settings-form"
115
+ disabled
116
+ required
117
+ data-testid="native-select"
118
+ inputProps={{ title: 'Select a flavor' }}
119
+ />
120
+ );
121
+
122
+ const select = screen.getByTestId('native-select');
123
+ expect(select).toHaveAttribute('form', 'settings-form');
124
+ expect(select).toHaveAttribute('title', 'Select a flavor');
125
+ expect(select).toBeDisabled();
126
+ expect(select).toBeRequired();
127
+ expect(select.parentElement).not.toHaveAttribute('form');
128
+ });
105
129
  });
106
130
 
107
131
  describe('States', () => {
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import classNames from 'classnames';
3
3
  import { generateResponsiveClasses } from '../../lib/generateResponsiveClasses';
4
4
  import { ResponsiveProp } from '../../types';
5
- import { Box, BoxProps } from '../Box/Box';
5
+ import { Box, BoxOwnProps, BoxProps, boxPropsKeys } from '../Box/Box';
6
6
  import { FormControl, FormControlProps } from '../FormControl/FormControl';
7
7
  import styles from './SelectInputNative.module.scss';
8
8
 
@@ -13,7 +13,7 @@ export interface SelectInputNativeOption {
13
13
  disabled?: boolean;
14
14
  }
15
15
 
16
- export interface SelectInputNativeProps extends BoxProps, FormControlProps {
16
+ interface SelectInputNativeOwnProps {
17
17
  /**
18
18
  * List of options for the select input.
19
19
  */
@@ -25,11 +25,36 @@ export interface SelectInputNativeProps extends BoxProps, FormControlProps {
25
25
  /**
26
26
  * Value of selected option. Should match the value key in the option object.
27
27
  */
28
- value: string | number | null;
28
+ value: React.ComponentPropsWithoutRef<'select'>['value'] | null;
29
+ /**
30
+ * Props passed directly to the select element of the component.
31
+ */
32
+ inputProps?: Omit<
33
+ BoxProps<'select'>,
34
+ | 'aria-label'
35
+ | 'aria-labelledby'
36
+ | 'aria-required'
37
+ | 'as'
38
+ | 'autoFocus'
39
+ | 'children'
40
+ | 'color'
41
+ | 'defaultValue'
42
+ | 'disabled'
43
+ | 'id'
44
+ | 'name'
45
+ | 'onChange'
46
+ | 'required'
47
+ | 'size'
48
+ | 'value'
49
+ >;
29
50
  /**
30
51
  * The input's 'name' attribute.
31
52
  */
32
53
  name?: string;
54
+ /**
55
+ * Placeholder option displayed before a value is selected.
56
+ */
57
+ placeholder?: string;
33
58
  /**
34
59
  * Visual indicator that the field is required, that gets appended to the label
35
60
  */
@@ -42,12 +67,38 @@ export interface SelectInputNativeProps extends BoxProps, FormControlProps {
42
67
  * Whether the input is autofocused on initial render.
43
68
  */
44
69
  autoFocus?: HTMLSelectElement['autofocus'];
45
- /**
46
- * Additional props to be spread.
47
- */
48
- [x: string]: any; // eslint-disable-line
49
70
  }
50
71
 
72
+ type SelectInputNativeFormControlProps = Pick<
73
+ FormControlProps,
74
+ | 'error'
75
+ | 'helpText'
76
+ | 'hideLabel'
77
+ | 'id'
78
+ | 'isDisabled'
79
+ | 'isRequired'
80
+ | 'label'
81
+ >;
82
+
83
+ type SelectInputNativeElementProps = Omit<
84
+ React.ComponentPropsWithoutRef<'select'>,
85
+ | keyof SelectInputNativeOwnProps
86
+ | keyof SelectInputNativeFormControlProps
87
+ | keyof BoxOwnProps
88
+ | 'aria-label'
89
+ | 'aria-labelledby'
90
+ | 'aria-required'
91
+ | 'children'
92
+ | 'dangerouslySetInnerHTML'
93
+ | 'defaultValue'
94
+ | 'size'
95
+ >;
96
+
97
+ export type SelectInputNativeProps = SelectInputNativeOwnProps &
98
+ SelectInputNativeFormControlProps &
99
+ Omit<BoxOwnProps, 'children'> &
100
+ SelectInputNativeElementProps;
101
+
51
102
  export const SelectInputNative: React.FC<SelectInputNativeProps> = ({
52
103
  autoFocus = false,
53
104
  label,
@@ -55,6 +106,7 @@ export const SelectInputNative: React.FC<SelectInputNativeProps> = ({
55
106
  helpText,
56
107
  error,
57
108
  id,
109
+ inputProps = {},
58
110
  isDisabled,
59
111
  isRequired,
60
112
  name,
@@ -66,6 +118,17 @@ export const SelectInputNative: React.FC<SelectInputNativeProps> = ({
66
118
  size = 'md',
67
119
  ...restProps
68
120
  }) => {
121
+ const isBoxProp = (key: string) =>
122
+ boxPropsKeys.includes(key as keyof BoxOwnProps);
123
+ const formControlProps = Object.fromEntries(
124
+ Object.entries(restProps).filter(([key]) => isBoxProp(key))
125
+ ) as Omit<BoxOwnProps, 'children'>;
126
+ const selectProps = Object.fromEntries(
127
+ Object.entries(restProps).filter(([key]) => !isBoxProp(key))
128
+ ) as SelectInputNativeElementProps;
129
+ const selectIsDisabled = isDisabled || selectProps.disabled;
130
+ const selectIsRequired = isRequired || selectProps.required;
131
+
69
132
  const placeholderOption: SelectInputNativeOption = {
70
133
  value: '',
71
134
  label: placeholder,
@@ -83,7 +146,7 @@ export const SelectInputNative: React.FC<SelectInputNativeProps> = ({
83
146
  styles['select-input-native-wrapper'],
84
147
  ...responsiveClasses.map((className) => styles[className]),
85
148
  {
86
- [styles.disabled]: isDisabled,
149
+ [styles.disabled]: selectIsDisabled,
87
150
  [styles.error]: error,
88
151
  }
89
152
  );
@@ -95,25 +158,27 @@ export const SelectInputNative: React.FC<SelectInputNativeProps> = ({
95
158
  id={id}
96
159
  error={error}
97
160
  helpText={helpText}
98
- isDisabled={isDisabled}
99
- isRequired={isRequired}
161
+ isDisabled={selectIsDisabled}
162
+ isRequired={selectIsRequired}
100
163
  requiredIndicator={requiredIndicator}
101
- {...restProps}
164
+ {...formControlProps}
102
165
  >
103
166
  <Box className={selectWrapperClasses}>
104
167
  <Box
105
168
  as="select"
169
+ {...inputProps}
170
+ {...selectProps}
106
171
  aria-label={label}
107
172
  aria-labelledby={label && !hideLabel ? `${id}Label` : undefined}
108
- aria-required={isRequired}
173
+ aria-required={selectIsRequired}
109
174
  value={value ?? ''}
110
175
  onChange={onChange}
111
176
  color={!value ? 'disabled' : 'base'}
112
177
  autoFocus={autoFocus}
113
- disabled={isDisabled}
178
+ disabled={selectIsDisabled}
114
179
  name={name}
115
180
  id={id}
116
- required={isRequired}
181
+ required={selectIsRequired}
117
182
  >
118
183
  {optionsWithPlaceholder.map((option) => (
119
184
  <Box
@@ -139,7 +139,7 @@ export const Switch: FC<SwitchProps> = ({
139
139
  alignItems: helpText ? 'flex-start' : ('center' as BoxProps['alignItems']),
140
140
  isFieldRequired: isRequired,
141
141
  requiredIndicator,
142
- size,
142
+ ...(typeof size === 'string' && { size }),
143
143
  };
144
144
 
145
145
  return (
@@ -30,6 +30,26 @@ An example showcasing the props required to render a list of customers in a tabl
30
30
 
31
31
  <Canvas of={Stories.CommonExample} />
32
32
 
33
+ ## TypeScript row types
34
+
35
+ `Table` accepts interfaces and infers its row type from `rows`. Type separately declared columns with `Column<TRow>[]` to validate `dataKey` values and type the `row` argument passed to render callbacks.
36
+
37
+ ```tsx
38
+ interface Customer {
39
+ id: string;
40
+ name: string;
41
+ }
42
+
43
+ const columns: Column<Customer>[] = [
44
+ {
45
+ dataKey: 'name',
46
+ render: (_cell, row) => row?.name,
47
+ },
48
+ ];
49
+
50
+ <Table columns={columns} rows={customers} rowKey="id" />;
51
+ ```
52
+
33
53
  ## Loading
34
54
 
35
55
  Use the `isLoading` prop on the `<Table />` component to display an overlay that prevents user interaction. Useful for when
@@ -146,7 +166,7 @@ The `Column` object is equiped with a `render` method. When used, the cells will
146
166
 
147
167
  The render method also exposes data about the cell and row, namely:
148
168
 
149
- `cell: the content in the cell,`
169
+ `cell: the content in the cell, typed as Cell — narrow it before rendering,`
150
170
 
151
171
  `row: the entire row object,`
152
172
 
@@ -106,7 +106,11 @@ export const Column = () =>
106
106
  {
107
107
  heading: 'Type',
108
108
  dataKey: 'type',
109
- render: (cell: string) => <code style={codePreviewStyle}>{cell}</code>,
109
+ render: (cell?: Cell) => (
110
+ <code style={codePreviewStyle}>
111
+ {typeof cell === 'string' || typeof cell === 'number' ? cell : null}
112
+ </code>
113
+ ),
110
114
  },
111
115
  { heading: 'Description', dataKey: 'description' },
112
116
  ];
@@ -1,4 +1,4 @@
1
- import React, { FC } from 'react';
1
+ import React from 'react';
2
2
  import classNames from 'classnames';
3
3
  import { Column, Row, EventWithColumnKey } from '../../types';
4
4
  import { Spinner } from '../Spinner/Spinner';
@@ -6,20 +6,20 @@ import styles from './Table.module.scss';
6
6
  import { TableBody } from './TableBody/TableBody';
7
7
  import { TableHead } from './TableHead/TableHead';
8
8
 
9
- export interface TableProps {
9
+ export interface TableProps<TRow extends object = Row> {
10
10
  /**
11
11
  * Columns for the table. See Column definition below for details.
12
12
  */
13
- columns: Column[];
13
+ columns: Column<TRow>[];
14
14
  /**
15
15
  * The data rows to be displayed
16
16
  */
17
- rows: Row[];
17
+ rows: TRow[];
18
18
  /**
19
19
  * Key that represents a unique value for a row. This is necessary in
20
20
  * order to supply React with a node key on each row.
21
21
  */
22
- rowKey: string;
22
+ rowKey: Extract<keyof TRow, string>;
23
23
  /**
24
24
  * Additional classes to add.
25
25
  */
@@ -90,7 +90,7 @@ export interface TableProps {
90
90
  truncateOverflow?: boolean;
91
91
  }
92
92
 
93
- export const Table: FC<TableProps> = ({
93
+ export const Table = <TRow extends object = Row,>({
94
94
  columns,
95
95
  rows,
96
96
  rowKey,
@@ -108,7 +108,7 @@ export const Table: FC<TableProps> = ({
108
108
  sortedColumn = undefined,
109
109
  useFixedTableLayout = false,
110
110
  truncateOverflow = false,
111
- }) => {
111
+ }: TableProps<TRow>) => {
112
112
  const containerClasses = classNames(styles.container, {
113
113
  [styles['full-height']]: !!isScrollable?.y,
114
114
  });
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
- import { render, screen } from '@testing-library/react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import type { Column } from '../../../types';
3
4
  import { TableBody } from './TableBody';
4
5
 
5
6
  const columns = [
@@ -14,6 +15,18 @@ const rows = [
14
15
  { id: 3, color: 'green', flavor: 'strawberry' },
15
16
  ];
16
17
 
18
+ const inputColumns: Column<(typeof rows)[number]>[] = [
19
+ {
20
+ dataKey: 'color',
21
+ render: (_cell, row) => (
22
+ <input
23
+ aria-label={`Color for row ${row?.id}`}
24
+ defaultValue={row?.color}
25
+ />
26
+ ),
27
+ },
28
+ ];
29
+
17
30
  describe('TableBody', () => {
18
31
  test("It renders with striped rows if passed 'isStriped' prop", () => {
19
32
  render(<TableBody columns={columns} rows={rows} rowKey="id" isStriped />);
@@ -35,4 +48,79 @@ describe('TableBody', () => {
35
48
  const tableBody = screen.getByRole('rowgroup');
36
49
  expect(tableBody).toHaveClass('my-custom-class');
37
50
  });
51
+
52
+ test('It preserves row identity when rows are reordered', () => {
53
+ const { rerender } = render(
54
+ <table>
55
+ <TableBody columns={inputColumns} rows={rows} rowKey="id" />
56
+ </table>
57
+ );
58
+
59
+ fireEvent.change(screen.getByLabelText('Color for row 1'), {
60
+ target: { value: 'purple' },
61
+ });
62
+
63
+ rerender(
64
+ <table>
65
+ <TableBody
66
+ columns={inputColumns}
67
+ rows={[...rows].reverse()}
68
+ rowKey="id"
69
+ />
70
+ </table>
71
+ );
72
+
73
+ expect(screen.getByLabelText('Color for row 1')).toHaveValue('purple');
74
+ });
75
+
76
+ test('It avoids duplicate keys when row key values cannot be used', () => {
77
+ const consoleError = jest.spyOn(console, 'error').mockImplementation();
78
+ const rowsWithoutUsableKeys = [
79
+ { id: undefined, color: 'red' },
80
+ { id: undefined, color: 'blue' },
81
+ { id: null, color: 'green' },
82
+ { id: null, color: 'yellow' },
83
+ { id: {}, color: 'black' },
84
+ { id: {}, color: 'white' },
85
+ ];
86
+
87
+ render(
88
+ <table>
89
+ <TableBody
90
+ columns={[{ dataKey: 'color' }]}
91
+ rows={rowsWithoutUsableKeys}
92
+ rowKey="id"
93
+ />
94
+ </table>
95
+ );
96
+
97
+ const duplicateKeyWarnings = consoleError.mock.calls.filter((call) =>
98
+ call.some(
99
+ (argument) =>
100
+ typeof argument === 'string' &&
101
+ argument.includes('Encountered two children with the same key')
102
+ )
103
+ );
104
+ consoleError.mockRestore();
105
+
106
+ expect(duplicateKeyWarnings).toHaveLength(0);
107
+ });
108
+
109
+ test('It handles nullish row values at runtime', () => {
110
+ const rowsWithNullishValues = [undefined, null] as unknown as typeof rows;
111
+
112
+ expect(() =>
113
+ render(
114
+ <table>
115
+ <TableBody
116
+ columns={columns}
117
+ rows={rowsWithNullishValues}
118
+ rowKey="id"
119
+ />
120
+ </table>
121
+ )
122
+ ).not.toThrow();
123
+
124
+ expect(screen.getAllByRole('row')).toHaveLength(2);
125
+ });
38
126
  });