@tcn/ui-transfer 1.0.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 (122) hide show
  1. package/AI_USAGE.md +16 -0
  2. package/README.md +3 -0
  3. package/dist/components/available_list/available_list.d.ts +11 -0
  4. package/dist/components/available_list/available_list.d.ts.map +1 -0
  5. package/dist/components/available_list/available_list.js +22 -0
  6. package/dist/components/available_list/available_list.js.map +1 -0
  7. package/dist/components/available_list/index.d.ts +2 -0
  8. package/dist/components/available_list/index.d.ts.map +1 -0
  9. package/dist/components/available_list/index.js +5 -0
  10. package/dist/components/available_list/index.js.map +1 -0
  11. package/dist/components/index.d.ts +4 -0
  12. package/dist/components/index.d.ts.map +1 -0
  13. package/dist/components/index.js +13 -0
  14. package/dist/components/index.js.map +1 -0
  15. package/dist/components/manage_list_item/addable_item.d.ts +5 -0
  16. package/dist/components/manage_list_item/addable_item.d.ts.map +1 -0
  17. package/dist/components/manage_list_item/addable_item.js +19 -0
  18. package/dist/components/manage_list_item/addable_item.js.map +1 -0
  19. package/dist/components/manage_list_item/index.d.ts +4 -0
  20. package/dist/components/manage_list_item/index.d.ts.map +1 -0
  21. package/dist/components/manage_list_item/index.js +9 -0
  22. package/dist/components/manage_list_item/index.js.map +1 -0
  23. package/dist/components/manage_list_item/manage_list_item.d.ts +11 -0
  24. package/dist/components/manage_list_item/manage_list_item.d.ts.map +1 -0
  25. package/dist/components/manage_list_item/manage_list_item.js +33 -0
  26. package/dist/components/manage_list_item/manage_list_item.js.map +1 -0
  27. package/dist/components/manage_list_item/removable_item.d.ts +5 -0
  28. package/dist/components/manage_list_item/removable_item.d.ts.map +1 -0
  29. package/dist/components/manage_list_item/removable_item.js +22 -0
  30. package/dist/components/manage_list_item/removable_item.js.map +1 -0
  31. package/dist/components/selected_list/index.d.ts +2 -0
  32. package/dist/components/selected_list/index.d.ts.map +1 -0
  33. package/dist/components/selected_list/index.js +5 -0
  34. package/dist/components/selected_list/index.js.map +1 -0
  35. package/dist/components/selected_list/selected_list.d.ts +13 -0
  36. package/dist/components/selected_list/selected_list.d.ts.map +1 -0
  37. package/dist/components/selected_list/selected_list.js +28 -0
  38. package/dist/components/selected_list/selected_list.js.map +1 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +19 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/removable_item.css +1 -0
  44. package/dist/transfer_list/index.d.ts +2 -0
  45. package/dist/transfer_list/index.d.ts.map +1 -0
  46. package/dist/transfer_list/index.js +5 -0
  47. package/dist/transfer_list/index.js.map +1 -0
  48. package/dist/transfer_list/transfer_list.d.ts +6 -0
  49. package/dist/transfer_list/transfer_list.d.ts.map +1 -0
  50. package/dist/transfer_list/transfer_list.js +12 -0
  51. package/dist/transfer_list/transfer_list.js.map +1 -0
  52. package/dist/transfer_table/components/available_table.d.ts +17 -0
  53. package/dist/transfer_table/components/available_table.d.ts.map +1 -0
  54. package/dist/transfer_table/components/available_table.js +68 -0
  55. package/dist/transfer_table/components/available_table.js.map +1 -0
  56. package/dist/transfer_table/components/available_table_filter_panel.d.ts +13 -0
  57. package/dist/transfer_table/components/available_table_filter_panel.d.ts.map +1 -0
  58. package/dist/transfer_table/components/available_table_filter_panel.js +39 -0
  59. package/dist/transfer_table/components/available_table_filter_panel.js.map +1 -0
  60. package/dist/transfer_table/components/available_table_header.d.ts +13 -0
  61. package/dist/transfer_table/components/available_table_header.d.ts.map +1 -0
  62. package/dist/transfer_table/components/available_table_header.js +43 -0
  63. package/dist/transfer_table/components/available_table_header.js.map +1 -0
  64. package/dist/transfer_table/components/selected_column.d.ts +15 -0
  65. package/dist/transfer_table/components/selected_column.d.ts.map +1 -0
  66. package/dist/transfer_table/components/selected_column.js +93 -0
  67. package/dist/transfer_table/components/selected_column.js.map +1 -0
  68. package/dist/transfer_table/components/selected_item.d.ts +8 -0
  69. package/dist/transfer_table/components/selected_item.d.ts.map +1 -0
  70. package/dist/transfer_table/components/selected_item.js +39 -0
  71. package/dist/transfer_table/components/selected_item.js.map +1 -0
  72. package/dist/transfer_table/index.d.ts +4 -0
  73. package/dist/transfer_table/index.d.ts.map +1 -0
  74. package/dist/transfer_table/index.js +7 -0
  75. package/dist/transfer_table/index.js.map +1 -0
  76. package/dist/transfer_table/transfer_table.d.ts +4 -0
  77. package/dist/transfer_table/transfer_table.d.ts.map +1 -0
  78. package/dist/transfer_table/transfer_table.js +82 -0
  79. package/dist/transfer_table/transfer_table.js.map +1 -0
  80. package/dist/transfer_table/transfer_table_presenter.d.ts +46 -0
  81. package/dist/transfer_table/transfer_table_presenter.d.ts.map +1 -0
  82. package/dist/transfer_table/transfer_table_presenter.js +98 -0
  83. package/dist/transfer_table/transfer_table_presenter.js.map +1 -0
  84. package/dist/transfer_table/types.d.ts +30 -0
  85. package/dist/transfer_table/types.d.ts.map +1 -0
  86. package/dist/transfer_table/types.js +2 -0
  87. package/dist/transfer_table/types.js.map +1 -0
  88. package/dist/transfer_table.css +1 -0
  89. package/dist/transfer_table.module-CI4PvlY3.js +5 -0
  90. package/dist/transfer_table.module-CI4PvlY3.js.map +1 -0
  91. package/package.json +81 -0
  92. package/src/__stories__/available_list.stories.tsx +41 -0
  93. package/src/__stories__/sample_data.ts +101 -0
  94. package/src/__stories__/selected_list.stories.tsx +41 -0
  95. package/src/__stories__/transfer_list.stories.tsx +13 -0
  96. package/src/__stories__/transfer_table.stories.tsx +128 -0
  97. package/src/__tests__/sanity.test.ts +7 -0
  98. package/src/components/available_list/available_list.tsx +39 -0
  99. package/src/components/available_list/index.ts +5 -0
  100. package/src/components/index.ts +19 -0
  101. package/src/components/manage_list_item/addable_item.tsx +22 -0
  102. package/src/components/manage_list_item/index.ts +7 -0
  103. package/src/components/manage_list_item/manage_list_item.tsx +43 -0
  104. package/src/components/manage_list_item/removable_item.module.css +3 -0
  105. package/src/components/manage_list_item/removable_item.tsx +25 -0
  106. package/src/components/selected_list/index.ts +5 -0
  107. package/src/components/selected_list/selected_list.tsx +50 -0
  108. package/src/index.ts +22 -0
  109. package/src/transfer_list/index.ts +1 -0
  110. package/src/transfer_list/transfer_list.tsx +14 -0
  111. package/src/transfer_table/components/available_table.tsx +80 -0
  112. package/src/transfer_table/components/available_table_filter_panel.tsx +62 -0
  113. package/src/transfer_table/components/available_table_header.tsx +60 -0
  114. package/src/transfer_table/components/selected_column.tsx +120 -0
  115. package/src/transfer_table/components/selected_item.tsx +58 -0
  116. package/src/transfer_table/index.ts +6 -0
  117. package/src/transfer_table/transfer_table.module.css +41 -0
  118. package/src/transfer_table/transfer_table.tsx +77 -0
  119. package/src/transfer_table/transfer_table_presenter.ts +164 -0
  120. package/src/transfer_table/types.ts +35 -0
  121. package/tsconfig.json +7 -0
  122. package/types/file_types.d.ts +106 -0
@@ -0,0 +1 @@
1
+ export { TransferList, type TransferListProps } from './transfer_list.js';
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import { Scaffold, type ScaffoldProps } from '@tcn/ui/layouts';
3
+
4
+ export interface TransferListProps extends ScaffoldProps {}
5
+
6
+ export const TransferList = React.forwardRef<HTMLElement, TransferListProps>(
7
+ function TransferList({ children, ...props }: TransferListProps, ref) {
8
+ return (
9
+ <Scaffold ref={ref} {...props}>
10
+ {children}
11
+ </Scaffold>
12
+ );
13
+ }
14
+ );
@@ -0,0 +1,80 @@
1
+ import { clsx } from 'clsx';
2
+ import { ArrowRightIcon } from '@tcn/icons/arrow_right_icon.js';
3
+ import { DataSource } from '@tcn/resource-store';
4
+ import { IBroadcast } from '@tcn/state';
5
+ import { FieldFilterProps, Table, TableColumn, TableColumnProps } from '@tcn/ui-table';
6
+ import { Button } from '@tcn/ui/actions';
7
+ import { Rail, Scaffold, type ScaffoldProps } from '@tcn/ui/layouts';
8
+ import React, { ReactElement } from 'react';
9
+ import styles from '../transfer_table.module.css';
10
+ import { AvailableTableFilterPanel } from './available_table_filter_panel.js';
11
+ import { AvailableTableHeader } from './available_table_header.js';
12
+
13
+ export interface AvailableTableOwnProps<T> {
14
+ dataSource: DataSource<T>;
15
+ columns: TableColumnProps<T>[];
16
+ filterPanelIsOpenBroadcast: IBroadcast<boolean>;
17
+ filterChildren?: ReactElement<FieldFilterProps> | ReactElement<FieldFilterProps>[];
18
+ onClickMove: (item: T) => void;
19
+ onClickFilterPanelToggle: () => void;
20
+ onClickMoveAll: () => void;
21
+ }
22
+
23
+ export type AvailableTableProps<T> = AvailableTableOwnProps<T> & ScaffoldProps;
24
+
25
+ export const AvailableTable = <T,>({
26
+ dataSource,
27
+ columns,
28
+ filterPanelIsOpenBroadcast,
29
+ filterChildren,
30
+ onClickMove,
31
+ onClickFilterPanelToggle,
32
+ onClickMoveAll,
33
+ className,
34
+ ...props
35
+ }: AvailableTableProps<T>) => {
36
+ return (
37
+ <Scaffold
38
+ className={clsx(
39
+ styles['tcn-table-available-table'],
40
+ 'tcn-table-available-table',
41
+ className
42
+ )}
43
+ {...props}
44
+ >
45
+ <AvailableTableHeader
46
+ dataSource={dataSource}
47
+ filterChildren={filterChildren}
48
+ onClickFilterPanelToggle={onClickFilterPanelToggle}
49
+ onClickMoveAll={onClickMoveAll}
50
+ />
51
+ <Rail>
52
+ <AvailableTableFilterPanel
53
+ dataSource={dataSource}
54
+ filterPanelIsOpenBroadcast={filterPanelIsOpenBroadcast}
55
+ filterChildren={filterChildren}
56
+ onClickFilterPanelToggle={onClickFilterPanelToggle}
57
+ />
58
+ {columns?.length > 0 ? (
59
+ <Table minWidth={200} dataSource={dataSource} className={styles['table-body']}>
60
+ {[
61
+ ...columns.map((columnProps, index) => (
62
+ <TableColumn key={columnProps.fieldName ?? index} {...columnProps} />
63
+ )),
64
+ <TableColumn
65
+ key="actions"
66
+ heading="Actions"
67
+ sticky="end"
68
+ render={(item: T) => (
69
+ <Button utility hierarchy="tertiary" onClick={() => onClickMove(item)}>
70
+ <ArrowRightIcon />
71
+ </Button>
72
+ )}
73
+ />,
74
+ ]}
75
+ </Table>
76
+ ) : null}
77
+ </Rail>
78
+ </Scaffold>
79
+ );
80
+ };
@@ -0,0 +1,62 @@
1
+ import { DataSource } from '@tcn/resource-store';
2
+ import { IBroadcast, useSignalValue } from '@tcn/state';
3
+ import {
4
+ FieldFilterProps,
5
+ TableFilterPanel,
6
+ type TableFilterPanelProps,
7
+ } from '@tcn/ui-table';
8
+ import { Resizable, ResizeHandle } from '@tcn/ui/utils';
9
+ import React, { ReactElement } from 'react';
10
+
11
+ export interface AvailableTableFilterPanelOwnProps<T> {
12
+ dataSource: DataSource<T>;
13
+ filterPanelIsOpenBroadcast: IBroadcast<boolean>;
14
+ filterChildren?: ReactElement<FieldFilterProps> | ReactElement<FieldFilterProps>[];
15
+ onClickFilterPanelToggle: () => void;
16
+ }
17
+
18
+ export type AvailableTableFilterPanelProps<T> = AvailableTableFilterPanelOwnProps<T> &
19
+ Omit<TableFilterPanelProps, 'children' | 'dataSource' | 'onClose'>;
20
+
21
+ function AvailableTableFilterPanelInner<T>(
22
+ {
23
+ dataSource,
24
+ filterPanelIsOpenBroadcast,
25
+ filterChildren,
26
+ onClickFilterPanelToggle,
27
+ minWidth = '200px',
28
+ maxWidth = '400px',
29
+ width = '296px',
30
+ ...props
31
+ }: AvailableTableFilterPanelProps<T>,
32
+ ref: React.ForwardedRef<HTMLElement>
33
+ ) {
34
+ const filterPanelIsOpen = useSignalValue(filterPanelIsOpenBroadcast);
35
+
36
+ if (!filterPanelIsOpen || !filterChildren) {
37
+ return null;
38
+ }
39
+
40
+ return (
41
+ <Resizable>
42
+ <TableFilterPanel
43
+ ref={ref}
44
+ dataSource={dataSource}
45
+ onClose={onClickFilterPanelToggle}
46
+ minWidth={minWidth}
47
+ maxWidth={maxWidth}
48
+ width={width}
49
+ {...props}
50
+ >
51
+ {filterChildren}
52
+ </TableFilterPanel>
53
+ <ResizeHandle position="end" />
54
+ </Resizable>
55
+ );
56
+ }
57
+
58
+ export const AvailableTableFilterPanel = React.forwardRef(
59
+ AvailableTableFilterPanelInner
60
+ ) as <T>(
61
+ props: AvailableTableFilterPanelProps<T> & React.RefAttributes<HTMLElement>
62
+ ) => React.ReactElement | null;
@@ -0,0 +1,60 @@
1
+ import { clsx } from 'clsx';
2
+ import { ArrowRightIcon } from '@tcn/icons/arrow_right_icon.js';
3
+ import { FilterTwoIcon } from '@tcn/icons/filter_two_icon.js';
4
+ import { DataSource } from '@tcn/resource-store';
5
+ import { FieldFilterProps, GlobalSearch } from '@tcn/ui-table';
6
+ import { Button } from '@tcn/ui/actions';
7
+ import { Group, UtilityBar, type HeaderProps } from '@tcn/ui/layouts';
8
+ import { Spacer } from '@tcn/ui/stacks';
9
+ import { Title } from '@tcn/ui/typography';
10
+ import React, { ReactElement } from 'react';
11
+ import styles from '../transfer_table.module.css';
12
+
13
+ export interface AvailableTableHeaderOwnProps<T> {
14
+ dataSource: DataSource<T>;
15
+ onClickFilterPanelToggle: () => void;
16
+ onClickMoveAll: () => void;
17
+ filterChildren?: ReactElement<FieldFilterProps> | ReactElement<FieldFilterProps>[];
18
+ }
19
+
20
+ export type AvailableTableHeaderProps<T> = AvailableTableHeaderOwnProps<T> & HeaderProps;
21
+
22
+ function AvailableTableHeaderInner<T>(
23
+ {
24
+ dataSource,
25
+ onClickFilterPanelToggle,
26
+ onClickMoveAll,
27
+ filterChildren,
28
+ className,
29
+ ...props
30
+ }: AvailableTableHeaderProps<T>,
31
+ ref: React.ForwardedRef<HTMLElement>
32
+ ) {
33
+ return (
34
+ <UtilityBar
35
+ ref={ref}
36
+ className={clsx(styles['available-table-header'], className)}
37
+ {...props}
38
+ >
39
+ <Group>
40
+ <Title emphasis="strong">Available</Title>
41
+ </Group>
42
+ {filterChildren && (
43
+ <Group>
44
+ <Button utility hierarchy="tertiary" onClick={onClickFilterPanelToggle}>
45
+ <FilterTwoIcon />
46
+ </Button>
47
+ </Group>
48
+ )}
49
+ <Spacer />
50
+ <GlobalSearch dataSource={dataSource} />
51
+ <Button hierarchy="tertiary" onClick={onClickMoveAll}>
52
+ Move All <ArrowRightIcon />
53
+ </Button>
54
+ </UtilityBar>
55
+ );
56
+ }
57
+
58
+ export const AvailableTableHeader = React.forwardRef(AvailableTableHeaderInner) as <T>(
59
+ props: AvailableTableHeaderProps<T> & React.RefAttributes<HTMLElement>
60
+ ) => React.ReactElement | null;
@@ -0,0 +1,120 @@
1
+ import { clsx } from 'clsx';
2
+ import { CrossCircleIcon } from '@tcn/icons/cross_circle_icon.js';
3
+ import { IBroadcast, useSignalValue } from '@tcn/state';
4
+ import { Button } from '@tcn/ui/actions';
5
+ import { Column, type ColumnProps, Detail, Scaffold, UtilityBar } from '@tcn/ui/layouts';
6
+ import { Spacer } from '@tcn/ui/stacks';
7
+ import { Title } from '@tcn/ui/typography';
8
+ import React, { useEffect, useRef } from 'react';
9
+ import { SelectedList } from '../../components/index.js';
10
+ import { SelectedItemsUpdate } from '../types.js';
11
+ import styles from '../transfer_table.module.css';
12
+ import { SelectedItem } from './selected_item.js';
13
+ import { Resizable, ResizeHandle } from '@tcn/ui/utils';
14
+
15
+ export interface SelectedColumnOwnProps<T> {
16
+ selectedItemsBroadcast: IBroadcast<SelectedItemsUpdate<T>>;
17
+ getSelectedItemKey: (item: T) => string | number;
18
+ selectedItemRender: (item: T) => React.ReactNode;
19
+ /** Receives the items that were removed (snapshot before the list is cleared). */
20
+ onRemoveAll: (removedItems: T[]) => void;
21
+ onRemove: (item: T) => void;
22
+ }
23
+
24
+ export type SelectedColumnProps<T> = SelectedColumnOwnProps<T> & ColumnProps;
25
+
26
+ function SelectedColumnInner<T>(
27
+ {
28
+ selectedItemsBroadcast,
29
+ getSelectedItemKey,
30
+ selectedItemRender,
31
+ onRemoveAll,
32
+ onRemove,
33
+ className,
34
+ width = '296px',
35
+ minWidth = '200px',
36
+ maxWidth = '50%',
37
+ ...props
38
+ }: SelectedColumnProps<T>,
39
+ ref: React.ForwardedRef<HTMLElement>
40
+ ) {
41
+ const { selectedItems, highlight } = useSignalValue(selectedItemsBroadcast);
42
+
43
+ // TODO: Remove highlight tracking once Presence is done (https://git.tcncloud.net/blackcat-ui/blackcat/-/issues/386)
44
+ const knownKeysRef = useRef<Set<string | number>>(new Set());
45
+ const newKeys = new Set<string | number>();
46
+ if (highlight) {
47
+ for (const item of selectedItems) {
48
+ const key = getSelectedItemKey(item);
49
+ if (!knownKeysRef.current.has(key)) {
50
+ newKeys.add(key);
51
+ }
52
+ }
53
+ }
54
+
55
+ useEffect(() => {
56
+ const currentKeys = new Set(selectedItems.map(getSelectedItemKey));
57
+ for (const key of currentKeys) {
58
+ knownKeysRef.current.add(key);
59
+ }
60
+ for (const key of [...knownKeysRef.current]) {
61
+ if (!currentKeys.has(key)) {
62
+ knownKeysRef.current.delete(key);
63
+ }
64
+ }
65
+ }, [selectedItems, getSelectedItemKey]);
66
+
67
+ return (
68
+ <Resizable>
69
+ <ResizeHandle position="start" />
70
+ <Column
71
+ ref={ref}
72
+ className={clsx(
73
+ styles['selected-list'],
74
+ 'tcn-transfer-table-selected-list',
75
+ className
76
+ )}
77
+ width={width}
78
+ minWidth={minWidth}
79
+ maxWidth={maxWidth}
80
+ {...props}
81
+ >
82
+ <Scaffold height="100%" width="100%">
83
+ <UtilityBar>
84
+ <Title emphasis="strong">Selected</Title>
85
+ <Spacer />
86
+ <Button hierarchy="tertiary" onClick={() => onRemoveAll(selectedItems)}>
87
+ Remove All <CrossCircleIcon />
88
+ </Button>
89
+ </UtilityBar>
90
+ <Scaffold>
91
+ <Detail>
92
+ <SelectedList
93
+ items={selectedItems}
94
+ getItemKey={getSelectedItemKey}
95
+ renderItem={selectedItemRender}
96
+ onClickItem={onRemove}
97
+ renderListItem={(item, content) => (
98
+ <SelectedItem
99
+ item={item}
100
+ onClickItem={onRemove}
101
+ isNew={newKeys.has(getSelectedItemKey(item))}
102
+ >
103
+ {content}
104
+ </SelectedItem>
105
+ )}
106
+ />
107
+ </Detail>
108
+ </Scaffold>
109
+ </Scaffold>
110
+ </Column>
111
+ </Resizable>
112
+ );
113
+ }
114
+
115
+ // React.forwardRef erases the generic <T> from SelectedColumnInner, returning
116
+ // ForwardRefExoticComponent<SelectedColumnProps<unknown>>. The cast restores the
117
+ // generic signature so consumers get proper type inference for their item type.
118
+ export const SelectedColumn = React.forwardRef(SelectedColumnInner) as <T>(
119
+ props: SelectedColumnProps<T> & React.RefAttributes<HTMLElement>
120
+ ) => React.ReactElement | null;
@@ -0,0 +1,58 @@
1
+ import { clsx } from 'clsx';
2
+ import React, { useEffect, useRef, useState } from 'react';
3
+ import { RemovableItem, type RemovableItemProps } from '../../components/index.js';
4
+ import styles from '../transfer_table.module.css';
5
+
6
+ export interface SelectedItemOwnProps {
7
+ isNew: boolean;
8
+ }
9
+
10
+ export type SelectedItemProps<T> = SelectedItemOwnProps & RemovableItemProps<T>;
11
+
12
+ function SelectedItemInner<T>(
13
+ { isNew: isNewProp, className, ...props }: SelectedItemProps<T>,
14
+ ref: React.ForwardedRef<HTMLDivElement>
15
+ ) {
16
+ const [isNew, setIsNew] = useState(isNewProp);
17
+ const timeoutIdRef = useRef<number | null>(null);
18
+
19
+ // TODO: Remove once Presence is done (https://git.tcncloud.net/blackcat-ui/blackcat/-/issues/386)
20
+ useEffect(() => {
21
+ if (isNewProp) {
22
+ setIsNew(true);
23
+ if (timeoutIdRef.current !== null) {
24
+ window.clearTimeout(timeoutIdRef.current);
25
+ }
26
+ const HIGHLIGHT_HOLD_MS = 1000;
27
+ timeoutIdRef.current = window.setTimeout(() => {
28
+ setIsNew(false);
29
+ timeoutIdRef.current = null;
30
+ }, HIGHLIGHT_HOLD_MS);
31
+ }
32
+ }, [isNewProp]);
33
+
34
+ // TODO: Remove once Presence is done (https://git.tcncloud.net/blackcat-ui/blackcat/-/issues/386)
35
+ useEffect(() => {
36
+ return () => {
37
+ if (timeoutIdRef.current !== null) {
38
+ window.clearTimeout(timeoutIdRef.current);
39
+ }
40
+ };
41
+ }, []);
42
+
43
+ return (
44
+ <RemovableItem
45
+ ref={ref}
46
+ className={clsx(
47
+ styles['selected-item'],
48
+ isNew && styles['selected-item-new'],
49
+ className
50
+ )}
51
+ {...props}
52
+ />
53
+ );
54
+ }
55
+
56
+ export const SelectedItem = React.forwardRef(SelectedItemInner) as <T>(
57
+ props: SelectedItemProps<T> & React.RefAttributes<HTMLDivElement>
58
+ ) => React.ReactElement | null;
@@ -0,0 +1,6 @@
1
+ export { TransferTable } from './transfer_table.js';
2
+ export { TransferTablePresenter } from './transfer_table_presenter.js';
3
+ export type {
4
+ SelectedItemsUpdate,
5
+ TransferTableProps,
6
+ } from './types.js';
@@ -0,0 +1,41 @@
1
+ /* Transfer Table */
2
+ .tcn-transfer-table {
3
+ --material: var(--background-color-primary);
4
+ :where(.tcn-table-available-table) :global(thead tr) {
5
+ --material: #e2e2e2;
6
+ }
7
+
8
+ /* TODO: should be on Theme - Column not direct child of page has border */
9
+ :global(.tcn-rail.tcn-columns) {
10
+ --resize-offset: -4px;
11
+ }
12
+
13
+ /* TODO: remove once theme provides a way to opt out of .tcn-panel .tcn-columns layout */
14
+ :global(.tcn-rail.tcn-columns) {
15
+ padding: 0;
16
+
17
+ :global(.tcn-column) {
18
+ gap: 0;
19
+ border-right: none;
20
+ padding-inline-end: 0;
21
+ padding-inline-start: 0;
22
+ }
23
+ }
24
+ }
25
+
26
+ .tcn-table-available-table {
27
+ /* TODO: should be on Theme - Column not direct child of page has border */
28
+ border-right: 1px solid #aaaaaa;
29
+ }
30
+
31
+ /* Selected Item
32
+ TODO: Remove highlight CSS once Presence is done (https://git.tcncloud.net/blackcat-ui/blackcat/-/issues/386).
33
+ --highlight-hold-ms (1000ms) must match the JS timeout in selected_item.tsx.
34
+ The fade-out duration (0.35s) kicks in after the JS class is removed. */
35
+ :where(.tcn-transfer-table) :where(.selected-item) {
36
+ transition: background-color 0.35s ease;
37
+ }
38
+
39
+ :where(.tcn-transfer-table) :where(.selected-item-new) {
40
+ background-color: var(--secondary-color-faint);
41
+ }
@@ -0,0 +1,77 @@
1
+ import { clsx } from 'clsx';
2
+ import { PlusIcon } from '@tcn/icons/plus_icon.js';
3
+ import { Button } from '@tcn/ui/actions';
4
+ import { Column, Columns, Footer, Scaffold } from '@tcn/ui/layouts';
5
+ import { Spacer } from '@tcn/ui/stacks';
6
+ import React from 'react';
7
+ import { AvailableTable } from './components/available_table.js';
8
+ import { SelectedColumn } from './components/selected_column.js';
9
+ import styles from './transfer_table.module.css';
10
+ import { TransferTableProps } from './types.js';
11
+
12
+ function TransferTableInner<T>(
13
+ {
14
+ dataSource,
15
+ columns,
16
+ onClickMoveAll,
17
+ onClickMove,
18
+ selectedItemsBroadcast,
19
+ getSelectedItemKey,
20
+ selectedItemRender,
21
+ onRemoveAll,
22
+ onRemove,
23
+ filterPanelIsOpenBroadcast,
24
+ filterChildren,
25
+ onClickFilterPanelToggle,
26
+ onClickCancel,
27
+ onClickAdd,
28
+ ...props
29
+ }: TransferTableProps<T>,
30
+ ref: React.ForwardedRef<HTMLElement>
31
+ ) {
32
+ return (
33
+ <Scaffold
34
+ ref={ref}
35
+ className={clsx(styles['tcn-transfer-table'], 'tcn-transfer-table', 'tcn-material')}
36
+ {...props}
37
+ >
38
+ <Columns>
39
+ <Column width="fill">
40
+ <AvailableTable
41
+ dataSource={dataSource}
42
+ columns={columns}
43
+ onClickMove={onClickMove}
44
+ onClickMoveAll={onClickMoveAll}
45
+ filterPanelIsOpenBroadcast={filterPanelIsOpenBroadcast}
46
+ filterChildren={filterChildren}
47
+ onClickFilterPanelToggle={onClickFilterPanelToggle}
48
+ />
49
+ </Column>
50
+
51
+ <SelectedColumn
52
+ selectedItemRender={selectedItemRender}
53
+ selectedItemsBroadcast={selectedItemsBroadcast}
54
+ getSelectedItemKey={getSelectedItemKey}
55
+ onRemoveAll={onRemoveAll}
56
+ onRemove={onRemove}
57
+ />
58
+ </Columns>
59
+ <Footer>
60
+ <Spacer />
61
+ <Button hierarchy="tertiary" onClick={onClickCancel}>
62
+ Cancel
63
+ </Button>
64
+ <Button
65
+ hierarchy="primary"
66
+ onClick={() => onClickAdd(selectedItemsBroadcast.get().selectedItems)}
67
+ >
68
+ Add <PlusIcon />
69
+ </Button>
70
+ </Footer>
71
+ </Scaffold>
72
+ );
73
+ }
74
+
75
+ export const TransferTable = React.forwardRef(TransferTableInner) as <T>(
76
+ props: TransferTableProps<T> & React.RefAttributes<HTMLElement>
77
+ ) => React.ReactElement | null;
@@ -0,0 +1,164 @@
1
+ import { StaticDataSource } from '@tcn/resource-store';
2
+ import { ISubscription, Signal } from '@tcn/state';
3
+ import { FieldFilterProps, TableColumnProps } from '@tcn/ui-table';
4
+ import { ReactElement } from 'react';
5
+ import { SelectedItemsUpdate, TransferTableProps } from './types.js';
6
+
7
+ export interface TransferTablePresenterArgs<T> {
8
+ dataSource: StaticDataSource<T>;
9
+ columns: TableColumnProps<T>[];
10
+ filterChildren?: ReactElement<FieldFilterProps> | ReactElement<FieldFilterProps>[];
11
+ getItemKey: (item: T) => string | number;
12
+ selectedItemRender: (item: T) => React.ReactNode;
13
+ onClickMoveAll?: (items: T[]) => void;
14
+ onClickMove?: (item: T) => void;
15
+ onRemoveAll?: (items: T[]) => void;
16
+ onRemove?: (item: T) => void;
17
+ onClickFilterPanelToggle?: () => void;
18
+ onClickCancel?: () => void;
19
+ onClickAdd?: (items: T[]) => void;
20
+ }
21
+
22
+ export class TransferTablePresenter<T> {
23
+ dataSource: StaticDataSource<T>;
24
+ columns: TableColumnProps<T>[];
25
+ getSelectedItemKey: (item: T) => string | number;
26
+
27
+ private selectedItemsSignal = new Signal<SelectedItemsUpdate<T>>({
28
+ selectedItems: [],
29
+ highlight: false,
30
+ });
31
+ selectedItemsBroadcast = this.selectedItemsSignal.broadcast;
32
+
33
+ selectedItemRender: (item: T) => React.ReactNode;
34
+ filterChildren?: ReactElement<FieldFilterProps> | ReactElement<FieldFilterProps>[];
35
+
36
+ private availableItems: T[] = [];
37
+ private _initialItemsSubscription: ISubscription<T[]> | null = null;
38
+
39
+ private filterPanelIsOpenSignal = new Signal<boolean>(false);
40
+ filterPanelIsOpenBroadcast = this.filterPanelIsOpenSignal.broadcast;
41
+
42
+ private callbacks: TransferTablePresenterArgs<T>;
43
+
44
+ constructor(args: TransferTablePresenterArgs<T>) {
45
+ this.dataSource = args.dataSource;
46
+ this.columns = args.columns;
47
+ this.selectedItemRender = args.selectedItemRender;
48
+ this.getSelectedItemKey = args.getItemKey;
49
+ this.filterChildren = args.filterChildren;
50
+ this.callbacks = args;
51
+
52
+ this._initialItemsSubscription = this.dataSource.broadcasts.currentResults.subscribe(
53
+ items => {
54
+ if (this.availableItems.length === 0) {
55
+ this.availableItems = [...items] as unknown as T[];
56
+ }
57
+ this._initialItemsSubscription?.unsubscribe();
58
+ this._initialItemsSubscription = null;
59
+ }
60
+ );
61
+ }
62
+
63
+ /** Load pre-selected items without triggering the highlight animation. */
64
+ loadItems = (items: T[]) => {
65
+ this.selectedItemsSignal.set({ selectedItems: items, highlight: false });
66
+ };
67
+
68
+ onClickMoveAll = () => {
69
+ const visibleItems = this.dataSource.broadcasts.currentResults.get();
70
+ const visibleKeys = new Set(visibleItems.map(this.getSelectedItemKey));
71
+ const currentSelectedItems = this.selectedItemsSignal.get().selectedItems;
72
+
73
+ this.availableItems = this.availableItems.filter(
74
+ i => !visibleKeys.has(this.getSelectedItemKey(i))
75
+ );
76
+ this.dataSource.setItems(this.availableItems);
77
+ this.selectedItemsSignal.set({
78
+ selectedItems: [...currentSelectedItems, ...visibleItems],
79
+ highlight: true,
80
+ });
81
+ this.callbacks.onClickMoveAll?.(visibleItems);
82
+ };
83
+
84
+ onClickMove = (item: T) => {
85
+ const itemKey = this.getSelectedItemKey(item);
86
+ this.availableItems = this.availableItems.filter(
87
+ i => this.getSelectedItemKey(i) !== itemKey
88
+ );
89
+
90
+ const currentSelectedItems = this.selectedItemsSignal.get().selectedItems;
91
+
92
+ this.dataSource.setItems(this.availableItems);
93
+ this.selectedItemsSignal.set({
94
+ selectedItems: [...currentSelectedItems, item],
95
+ highlight: true,
96
+ });
97
+ this.callbacks.onClickMove?.(item);
98
+ };
99
+
100
+ onRemoveAll = () => {
101
+ const currentSelectedItems = this.selectedItemsSignal.get().selectedItems;
102
+
103
+ this.availableItems = [...this.availableItems, ...currentSelectedItems];
104
+ this.dataSource.setItems(this.availableItems);
105
+ this.selectedItemsSignal.set({ selectedItems: [], highlight: false });
106
+ this.callbacks.onRemoveAll?.(currentSelectedItems);
107
+ };
108
+
109
+ onRemove = (removeItem: T) => {
110
+ const removeKey = this.getSelectedItemKey(removeItem);
111
+ const currentSelectedItems = this.selectedItemsSignal.get().selectedItems;
112
+ const mutatedSelectedItems = currentSelectedItems.filter(
113
+ item => this.getSelectedItemKey(item) !== removeKey
114
+ );
115
+
116
+ this.availableItems = [...this.availableItems, removeItem];
117
+ this.dataSource.setItems(this.availableItems);
118
+ this.selectedItemsSignal.set({
119
+ selectedItems: mutatedSelectedItems,
120
+ highlight: false,
121
+ });
122
+ this.callbacks.onRemove?.(removeItem);
123
+ };
124
+
125
+ onClickFilterPanelToggle = () => {
126
+ const currentOpenValue = this.filterPanelIsOpenSignal.get();
127
+ this.filterPanelIsOpenSignal.set(!currentOpenValue);
128
+ this.callbacks.onClickFilterPanelToggle?.();
129
+ };
130
+
131
+ onClickCancel = () => {
132
+ this.callbacks.onClickCancel?.();
133
+ };
134
+
135
+ onClickAdd = (items: T[]) => {
136
+ this.callbacks.onClickAdd?.(items);
137
+ };
138
+
139
+ /** Returns only the props the TransferTable component needs. */
140
+ getProps(): TransferTableProps<T> {
141
+ return {
142
+ dataSource: this.dataSource,
143
+ columns: this.columns,
144
+ onClickMoveAll: this.onClickMoveAll,
145
+ onClickMove: this.onClickMove,
146
+ selectedItemsBroadcast: this.selectedItemsBroadcast,
147
+ getSelectedItemKey: this.getSelectedItemKey,
148
+ selectedItemRender: this.selectedItemRender,
149
+ onRemoveAll: this.onRemoveAll,
150
+ onRemove: this.onRemove,
151
+ filterPanelIsOpenBroadcast: this.filterPanelIsOpenBroadcast,
152
+ filterChildren: this.filterChildren,
153
+ onClickFilterPanelToggle: this.onClickFilterPanelToggle,
154
+ onClickCancel: this.onClickCancel,
155
+ onClickAdd: this.onClickAdd,
156
+ };
157
+ }
158
+
159
+ dispose(): void {
160
+ this._initialItemsSubscription?.unsubscribe();
161
+ this.selectedItemsSignal.dispose();
162
+ this.filterPanelIsOpenSignal.dispose();
163
+ }
164
+ }