tekivex-ui 2.0.2 → 2.2.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.
- package/dist/index.cjs +12 -12
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2839 -2538
- package/dist/src/components/TkxAppBar.d.ts +12 -0
- package/dist/src/components/TkxAppBar.d.ts.map +1 -0
- package/dist/src/components/TkxAutocomplete.d.ts +25 -0
- package/dist/src/components/TkxAutocomplete.d.ts.map +1 -0
- package/dist/src/components/TkxBottomNav.d.ts +15 -0
- package/dist/src/components/TkxBottomNav.d.ts.map +1 -0
- package/dist/src/components/TkxBreadcrumb.d.ts +17 -0
- package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -0
- package/dist/src/components/TkxCard.d.ts.map +1 -1
- package/dist/src/components/TkxConfigProvider.d.ts +100 -0
- package/dist/src/components/TkxConfigProvider.d.ts.map +1 -0
- package/dist/src/components/TkxDataGrid.d.ts +33 -0
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
- package/dist/src/components/TkxEmpty.d.ts +870 -0
- package/dist/src/components/TkxEmpty.d.ts.map +1 -0
- package/dist/src/components/TkxForm.d.ts +54 -0
- package/dist/src/components/TkxForm.d.ts.map +1 -0
- package/dist/src/components/TkxLayout.d.ts +87 -0
- package/dist/src/components/TkxLayout.d.ts.map +1 -0
- package/dist/src/components/TkxMasonry.d.ts +12 -0
- package/dist/src/components/TkxMasonry.d.ts.map +1 -0
- package/dist/src/components/TkxModal.d.ts.map +1 -1
- package/dist/src/components/TkxPopover.d.ts +15 -0
- package/dist/src/components/TkxPopover.d.ts.map +1 -0
- package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
- package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts.map +1 -1
- package/dist/src/components/TkxSlider.d.ts.map +1 -1
- package/dist/src/components/TkxSnackbar.d.ts +16 -0
- package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
- package/dist/src/components/TkxSpeedDial.d.ts +18 -0
- package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
- package/dist/src/components/TkxSpin.d.ts +15 -0
- package/dist/src/components/TkxSpin.d.ts.map +1 -0
- package/dist/src/components/TkxStatistic.d.ts +1746 -0
- package/dist/src/components/TkxStatistic.d.ts.map +1 -0
- package/dist/src/components/TkxToolbar.d.ts +22 -0
- package/dist/src/components/TkxToolbar.d.ts.map +1 -0
- package/dist/src/components/TkxTooltip.d.ts.map +1 -1
- package/dist/src/components/TkxTransferList.d.ts +20 -0
- package/dist/src/components/TkxTransferList.d.ts.map +1 -0
- package/dist/src/components/TkxTreeView.d.ts +23 -0
- package/dist/src/components/TkxTreeView.d.ts.map +1 -0
- package/dist/src/components/TkxTypography.d.ts +2614 -0
- package/dist/src/components/TkxTypography.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +20 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/i18n/I18nProvider.d.ts +10 -0
- package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
- package/dist/src/i18n/index.d.ts +50 -0
- package/dist/src/i18n/index.d.ts.map +1 -0
- package/dist/src/themes/index.d.ts +108 -0
- package/dist/src/themes/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/TkxAppBar.tsx +260 -0
- package/src/components/TkxAutocomplete.tsx +453 -0
- package/src/components/TkxBottomNav.tsx +239 -0
- package/src/components/TkxBreadcrumb.tsx +322 -0
- package/src/components/TkxCard.tsx +3 -1
- package/src/components/TkxConfigProvider.tsx +458 -0
- package/src/components/TkxDataGrid.tsx +540 -0
- package/src/components/TkxEmpty.tsx +219 -0
- package/src/components/TkxForm.tsx +607 -0
- package/src/components/TkxLayout.tsx +647 -0
- package/src/components/TkxMasonry.tsx +176 -0
- package/src/components/TkxModal.tsx +4 -3
- package/src/components/TkxPopover.tsx +295 -0
- package/src/components/TkxRichTextDisplay.tsx +394 -0
- package/src/components/TkxSelect.tsx +2 -0
- package/src/components/TkxSlider.tsx +1 -0
- package/src/components/TkxSnackbar.tsx +314 -0
- package/src/components/TkxSpeedDial.tsx +395 -0
- package/src/components/TkxSpin.tsx +261 -0
- package/src/components/TkxStatistic.tsx +261 -0
- package/src/components/TkxToolbar.tsx +260 -0
- package/src/components/TkxTooltip.tsx +3 -2
- package/src/components/TkxTransferList.tsx +487 -0
- package/src/components/TkxTreeView.tsx +421 -0
- package/src/components/TkxTypography.tsx +263 -0
- package/src/components/index.ts +20 -0
- package/src/hooks/index.ts +1 -1
- package/src/i18n/I18nProvider.tsx +36 -0
- package/src/i18n/index.ts +237 -0
- package/src/themes/index.ts +149 -0
- package/dist/tests/TkxButton.test.d.ts +0 -2
- package/dist/tests/TkxButton.test.d.ts.map +0 -1
- package/dist/tests/a11y/components.test.d.ts +0 -2
- package/dist/tests/a11y/components.test.d.ts.map +0 -1
- package/dist/tests/quantum.test.d.ts +0 -2
- package/dist/tests/quantum.test.d.ts.map +0 -1
- package/dist/tests/security.test.d.ts +0 -2
- package/dist/tests/security.test.d.ts.map +0 -1
- package/dist/tests/setup.d.ts +0 -2
- package/dist/tests/setup.d.ts.map +0 -1
- package/dist/tests/tkx.test.d.ts +0 -2
- package/dist/tests/tkx.test.d.ts.map +0 -1
- package/dist/tests/wcag.test.d.ts +0 -2
- package/dist/tests/wcag.test.d.ts.map +0 -1
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useMemo,
|
|
4
|
+
useCallback,
|
|
5
|
+
useId,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { useTheme } from '../themes';
|
|
10
|
+
import { sanitizeString } from '../engine/security';
|
|
11
|
+
import { useReducedMotion } from '../hooks';
|
|
12
|
+
import { tkx } from '../engine/tkx';
|
|
13
|
+
|
|
14
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface TransferItem {
|
|
17
|
+
value: string;
|
|
18
|
+
label: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TkxTransferListProps {
|
|
23
|
+
sourceItems: TransferItem[];
|
|
24
|
+
targetItems: TransferItem[];
|
|
25
|
+
onTransfer: (source: TransferItem[], target: TransferItem[]) => void;
|
|
26
|
+
sourceTitle?: string;
|
|
27
|
+
targetTitle?: string;
|
|
28
|
+
searchable?: boolean;
|
|
29
|
+
height?: number;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Search icon ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function SearchIcon({ color }: { color: string }) {
|
|
37
|
+
return (
|
|
38
|
+
<svg
|
|
39
|
+
width="14"
|
|
40
|
+
height="14"
|
|
41
|
+
viewBox="0 0 14 14"
|
|
42
|
+
fill="none"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
>
|
|
45
|
+
<circle cx="6" cy="6" r="4.5" stroke={color} strokeWidth="1.5" />
|
|
46
|
+
<path d="M9.5 9.5L13 13" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
|
47
|
+
</svg>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Checkbox ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function Checkbox({
|
|
54
|
+
checked,
|
|
55
|
+
primaryColor,
|
|
56
|
+
borderColor,
|
|
57
|
+
}: {
|
|
58
|
+
checked: boolean;
|
|
59
|
+
primaryColor: string;
|
|
60
|
+
borderColor: string;
|
|
61
|
+
}) {
|
|
62
|
+
return (
|
|
63
|
+
<span
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
className={tkx('inline-flex items-center justify-center flex-shrink-0')}
|
|
66
|
+
style={{
|
|
67
|
+
width: 16,
|
|
68
|
+
height: 16,
|
|
69
|
+
borderRadius: 3,
|
|
70
|
+
border: `1.5px solid ${checked ? primaryColor : borderColor}`,
|
|
71
|
+
backgroundColor: checked ? primaryColor : 'transparent',
|
|
72
|
+
transition: 'all 100ms ease',
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{checked && (
|
|
76
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
77
|
+
<path
|
|
78
|
+
d="M2 5l2.5 2.5L8 3"
|
|
79
|
+
stroke="#fff"
|
|
80
|
+
strokeWidth="1.5"
|
|
81
|
+
strokeLinecap="round"
|
|
82
|
+
strokeLinejoin="round"
|
|
83
|
+
/>
|
|
84
|
+
</svg>
|
|
85
|
+
)}
|
|
86
|
+
</span>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Arrow icons ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function ArrowRightIcon({ color }: { color: string }) {
|
|
93
|
+
return (
|
|
94
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
95
|
+
<path d="M3 8h10M9 4l4 4-4 4" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
96
|
+
</svg>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ArrowLeftIcon({ color }: { color: string }) {
|
|
101
|
+
return (
|
|
102
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
103
|
+
<path d="M13 8H3M7 4l-4 4 4 4" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
104
|
+
</svg>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Internal list panel ─────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
interface ListPanelProps {
|
|
111
|
+
title: string;
|
|
112
|
+
items: TransferItem[];
|
|
113
|
+
selected: Set<string>;
|
|
114
|
+
onToggle: (value: string) => void;
|
|
115
|
+
onToggleAll: () => void;
|
|
116
|
+
search: string;
|
|
117
|
+
onSearchChange: (s: string) => void;
|
|
118
|
+
searchable: boolean;
|
|
119
|
+
height: number;
|
|
120
|
+
listId: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ListPanel({
|
|
124
|
+
title,
|
|
125
|
+
items,
|
|
126
|
+
selected,
|
|
127
|
+
onToggle,
|
|
128
|
+
onToggleAll,
|
|
129
|
+
search,
|
|
130
|
+
onSearchChange,
|
|
131
|
+
searchable,
|
|
132
|
+
height,
|
|
133
|
+
listId,
|
|
134
|
+
}: ListPanelProps) {
|
|
135
|
+
const theme = useTheme();
|
|
136
|
+
const reducedMotion = useReducedMotion();
|
|
137
|
+
|
|
138
|
+
const safeTitle = sanitizeString(title);
|
|
139
|
+
|
|
140
|
+
const filtered = useMemo(() => {
|
|
141
|
+
if (!search) return items;
|
|
142
|
+
const q = search.toLowerCase();
|
|
143
|
+
return items.filter((item) => item.label.toLowerCase().includes(q));
|
|
144
|
+
}, [items, search]);
|
|
145
|
+
|
|
146
|
+
const enabledItems = filtered.filter((i) => !i.disabled);
|
|
147
|
+
const allSelected = enabledItems.length > 0 && enabledItems.every((i) => selected.has(i.value));
|
|
148
|
+
const someSelected = enabledItems.some((i) => selected.has(i.value));
|
|
149
|
+
|
|
150
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLElement>, value: string) => {
|
|
151
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
onToggle(value);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
className={tkx('flex flex-col rounded-lg overflow-hidden flex-1 min-w-0')}
|
|
160
|
+
style={{
|
|
161
|
+
border: `1px solid ${theme.border}`,
|
|
162
|
+
backgroundColor: theme.surface,
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
{/* Header */}
|
|
166
|
+
<div
|
|
167
|
+
className={tkx('flex items-center gap-2 px-3 py-2 border-b')}
|
|
168
|
+
style={{ borderColor: theme.border, backgroundColor: theme.surfaceAlt }}
|
|
169
|
+
>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
aria-label={allSelected ? `Deselect all ${safeTitle}` : `Select all ${safeTitle}`}
|
|
173
|
+
onClick={onToggleAll}
|
|
174
|
+
className={tkx('border-none bg-transparent cursor-pointer p-0 outline-none focus-visible:ring-2')}
|
|
175
|
+
>
|
|
176
|
+
<Checkbox
|
|
177
|
+
checked={allSelected}
|
|
178
|
+
primaryColor={theme.primary}
|
|
179
|
+
borderColor={theme.border}
|
|
180
|
+
/>
|
|
181
|
+
</button>
|
|
182
|
+
<span
|
|
183
|
+
className={tkx('text-sm font-medium flex-1 truncate')}
|
|
184
|
+
style={{ color: theme.text }}
|
|
185
|
+
>
|
|
186
|
+
{safeTitle}
|
|
187
|
+
</span>
|
|
188
|
+
<span
|
|
189
|
+
className={tkx('text-xs')}
|
|
190
|
+
style={{ color: theme.textMuted }}
|
|
191
|
+
>
|
|
192
|
+
{selected.size}/{items.length}
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Search */}
|
|
197
|
+
{searchable && (
|
|
198
|
+
<div
|
|
199
|
+
className={tkx('flex items-center gap-1.5 px-3 py-1.5 border-b')}
|
|
200
|
+
style={{ borderColor: theme.border }}
|
|
201
|
+
>
|
|
202
|
+
<SearchIcon color={theme.textMuted} />
|
|
203
|
+
<input
|
|
204
|
+
type="text"
|
|
205
|
+
aria-label={`Search ${safeTitle}`}
|
|
206
|
+
value={search}
|
|
207
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
208
|
+
placeholder="Search..."
|
|
209
|
+
className={tkx('flex-1 bg-transparent border-none outline-none text-xs')}
|
|
210
|
+
style={{ color: theme.text }}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{/* Items list */}
|
|
216
|
+
<ul
|
|
217
|
+
id={listId}
|
|
218
|
+
role="listbox"
|
|
219
|
+
aria-label={safeTitle}
|
|
220
|
+
aria-multiselectable="true"
|
|
221
|
+
className={tkx('list-none m-0 p-1 overflow-y-auto')}
|
|
222
|
+
style={{ height, minHeight: 100 }}
|
|
223
|
+
>
|
|
224
|
+
{filtered.length === 0 ? (
|
|
225
|
+
<li
|
|
226
|
+
className={tkx('text-xs text-center py-4')}
|
|
227
|
+
style={{ color: theme.textMuted }}
|
|
228
|
+
aria-disabled="true"
|
|
229
|
+
>
|
|
230
|
+
No items
|
|
231
|
+
</li>
|
|
232
|
+
) : (
|
|
233
|
+
filtered.map((item) => {
|
|
234
|
+
const isChecked = selected.has(item.value);
|
|
235
|
+
const safeLabel = sanitizeString(item.label);
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<li
|
|
239
|
+
key={item.value}
|
|
240
|
+
role="option"
|
|
241
|
+
aria-selected={isChecked}
|
|
242
|
+
aria-disabled={item.disabled || undefined}
|
|
243
|
+
tabIndex={0}
|
|
244
|
+
onClick={() => !item.disabled && onToggle(item.value)}
|
|
245
|
+
onKeyDown={(e) => !item.disabled && handleKeyDown(e, item.value)}
|
|
246
|
+
className={tkx(
|
|
247
|
+
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-sm',
|
|
248
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
|
|
249
|
+
item.disabled ? 'opacity-50 cursor-not-allowed' : '',
|
|
250
|
+
)}
|
|
251
|
+
style={{
|
|
252
|
+
color: item.disabled ? theme.textMuted : theme.text,
|
|
253
|
+
backgroundColor: isChecked ? `${theme.primary}10` : 'transparent',
|
|
254
|
+
transition: reducedMotion ? 'none' : 'background-color 100ms ease',
|
|
255
|
+
}}
|
|
256
|
+
onMouseEnter={(e) => {
|
|
257
|
+
if (!item.disabled && !isChecked) {
|
|
258
|
+
(e.currentTarget as HTMLElement).style.backgroundColor = theme.surfaceAlt;
|
|
259
|
+
}
|
|
260
|
+
}}
|
|
261
|
+
onMouseLeave={(e) => {
|
|
262
|
+
(e.currentTarget as HTMLElement).style.backgroundColor = isChecked
|
|
263
|
+
? `${theme.primary}10`
|
|
264
|
+
: 'transparent';
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
<Checkbox
|
|
268
|
+
checked={isChecked}
|
|
269
|
+
primaryColor={theme.primary}
|
|
270
|
+
borderColor={theme.border}
|
|
271
|
+
/>
|
|
272
|
+
<span className={tkx('truncate')}>{safeLabel}</span>
|
|
273
|
+
</li>
|
|
274
|
+
);
|
|
275
|
+
})
|
|
276
|
+
)}
|
|
277
|
+
</ul>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Main Component ──────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
export function TkxTransferList({
|
|
285
|
+
sourceItems,
|
|
286
|
+
targetItems,
|
|
287
|
+
onTransfer,
|
|
288
|
+
sourceTitle = 'Available',
|
|
289
|
+
targetTitle = 'Selected',
|
|
290
|
+
searchable = false,
|
|
291
|
+
height = 300,
|
|
292
|
+
className,
|
|
293
|
+
style,
|
|
294
|
+
}: TkxTransferListProps) {
|
|
295
|
+
const theme = useTheme();
|
|
296
|
+
const reducedMotion = useReducedMotion();
|
|
297
|
+
const sourceListId = useId();
|
|
298
|
+
const targetListId = useId();
|
|
299
|
+
|
|
300
|
+
const [sourceSelected, setSourceSelected] = useState<Set<string>>(new Set());
|
|
301
|
+
const [targetSelected, setTargetSelected] = useState<Set<string>>(new Set());
|
|
302
|
+
const [sourceSearch, setSourceSearch] = useState('');
|
|
303
|
+
const [targetSearch, setTargetSearch] = useState('');
|
|
304
|
+
|
|
305
|
+
const toggleSourceItem = useCallback((value: string) => {
|
|
306
|
+
setSourceSelected((prev) => {
|
|
307
|
+
const next = new Set(prev);
|
|
308
|
+
if (next.has(value)) next.delete(value);
|
|
309
|
+
else next.add(value);
|
|
310
|
+
return next;
|
|
311
|
+
});
|
|
312
|
+
}, []);
|
|
313
|
+
|
|
314
|
+
const toggleTargetItem = useCallback((value: string) => {
|
|
315
|
+
setTargetSelected((prev) => {
|
|
316
|
+
const next = new Set(prev);
|
|
317
|
+
if (next.has(value)) next.delete(value);
|
|
318
|
+
else next.add(value);
|
|
319
|
+
return next;
|
|
320
|
+
});
|
|
321
|
+
}, []);
|
|
322
|
+
|
|
323
|
+
const toggleAllSource = useCallback(() => {
|
|
324
|
+
const enabledValues = sourceItems.filter((i) => !i.disabled).map((i) => i.value);
|
|
325
|
+
const allSelected = enabledValues.every((v) => sourceSelected.has(v));
|
|
326
|
+
if (allSelected) {
|
|
327
|
+
setSourceSelected(new Set());
|
|
328
|
+
} else {
|
|
329
|
+
setSourceSelected(new Set(enabledValues));
|
|
330
|
+
}
|
|
331
|
+
}, [sourceItems, sourceSelected]);
|
|
332
|
+
|
|
333
|
+
const toggleAllTarget = useCallback(() => {
|
|
334
|
+
const enabledValues = targetItems.filter((i) => !i.disabled).map((i) => i.value);
|
|
335
|
+
const allSelected = enabledValues.every((v) => targetSelected.has(v));
|
|
336
|
+
if (allSelected) {
|
|
337
|
+
setTargetSelected(new Set());
|
|
338
|
+
} else {
|
|
339
|
+
setTargetSelected(new Set(enabledValues));
|
|
340
|
+
}
|
|
341
|
+
}, [targetItems, targetSelected]);
|
|
342
|
+
|
|
343
|
+
// Transfer selected items to target
|
|
344
|
+
const moveToTarget = useCallback(() => {
|
|
345
|
+
if (sourceSelected.size === 0) return;
|
|
346
|
+
const toMove = sourceItems.filter((i) => sourceSelected.has(i.value) && !i.disabled);
|
|
347
|
+
const remainingSource = sourceItems.filter((i) => !sourceSelected.has(i.value) || i.disabled);
|
|
348
|
+
const newTarget = [...targetItems, ...toMove];
|
|
349
|
+
onTransfer(remainingSource, newTarget);
|
|
350
|
+
setSourceSelected(new Set());
|
|
351
|
+
}, [sourceItems, targetItems, sourceSelected, onTransfer]);
|
|
352
|
+
|
|
353
|
+
// Transfer selected items back to source
|
|
354
|
+
const moveToSource = useCallback(() => {
|
|
355
|
+
if (targetSelected.size === 0) return;
|
|
356
|
+
const toMove = targetItems.filter((i) => targetSelected.has(i.value) && !i.disabled);
|
|
357
|
+
const remainingTarget = targetItems.filter((i) => !targetSelected.has(i.value) || i.disabled);
|
|
358
|
+
const newSource = [...sourceItems, ...toMove];
|
|
359
|
+
onTransfer(newSource, remainingTarget);
|
|
360
|
+
setTargetSelected(new Set());
|
|
361
|
+
}, [sourceItems, targetItems, targetSelected, onTransfer]);
|
|
362
|
+
|
|
363
|
+
const transferButtonStyle = (disabled: boolean): CSSProperties => ({
|
|
364
|
+
display: 'inline-flex',
|
|
365
|
+
alignItems: 'center',
|
|
366
|
+
justifyContent: 'center',
|
|
367
|
+
width: 36,
|
|
368
|
+
height: 36,
|
|
369
|
+
borderRadius: 8,
|
|
370
|
+
border: `1px solid ${theme.border}`,
|
|
371
|
+
backgroundColor: disabled ? theme.surface : theme.surfaceAlt,
|
|
372
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
373
|
+
opacity: disabled ? 0.5 : 1,
|
|
374
|
+
transition: reducedMotion ? 'none' : 'all 100ms ease',
|
|
375
|
+
outline: 'none',
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return (
|
|
379
|
+
<div
|
|
380
|
+
className={tkx(
|
|
381
|
+
'flex gap-3 font-sans',
|
|
382
|
+
// Responsive: stack vertically on narrow containers
|
|
383
|
+
'flex-col sm:flex-row',
|
|
384
|
+
className ?? '',
|
|
385
|
+
)}
|
|
386
|
+
style={{
|
|
387
|
+
color: theme.text,
|
|
388
|
+
...style,
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
{/* Source list */}
|
|
392
|
+
<ListPanel
|
|
393
|
+
title={sourceTitle}
|
|
394
|
+
items={sourceItems}
|
|
395
|
+
selected={sourceSelected}
|
|
396
|
+
onToggle={toggleSourceItem}
|
|
397
|
+
onToggleAll={toggleAllSource}
|
|
398
|
+
search={sourceSearch}
|
|
399
|
+
onSearchChange={setSourceSearch}
|
|
400
|
+
searchable={searchable}
|
|
401
|
+
height={height}
|
|
402
|
+
listId={sourceListId}
|
|
403
|
+
/>
|
|
404
|
+
|
|
405
|
+
{/* Transfer buttons */}
|
|
406
|
+
<div
|
|
407
|
+
className={tkx(
|
|
408
|
+
'flex items-center justify-center gap-2',
|
|
409
|
+
'flex-row sm:flex-col',
|
|
410
|
+
)}
|
|
411
|
+
style={{ padding: '8px 0' }}
|
|
412
|
+
>
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
aria-label="Move selected items to target list"
|
|
416
|
+
disabled={sourceSelected.size === 0}
|
|
417
|
+
onClick={moveToTarget}
|
|
418
|
+
onKeyDown={(e) => {
|
|
419
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
moveToTarget();
|
|
422
|
+
}
|
|
423
|
+
}}
|
|
424
|
+
className={tkx('focus-visible:ring-2 focus-visible:ring-offset-1')}
|
|
425
|
+
style={transferButtonStyle(sourceSelected.size === 0)}
|
|
426
|
+
onMouseEnter={(e) => {
|
|
427
|
+
if (sourceSelected.size > 0) {
|
|
428
|
+
(e.currentTarget as HTMLElement).style.backgroundColor = theme.primary;
|
|
429
|
+
(e.currentTarget as HTMLElement).style.borderColor = theme.primary;
|
|
430
|
+
}
|
|
431
|
+
}}
|
|
432
|
+
onMouseLeave={(e) => {
|
|
433
|
+
(e.currentTarget as HTMLElement).style.backgroundColor =
|
|
434
|
+
sourceSelected.size === 0 ? theme.surface : theme.surfaceAlt;
|
|
435
|
+
(e.currentTarget as HTMLElement).style.borderColor = theme.border;
|
|
436
|
+
}}
|
|
437
|
+
>
|
|
438
|
+
<ArrowRightIcon color={theme.text} />
|
|
439
|
+
</button>
|
|
440
|
+
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
aria-label="Move selected items to source list"
|
|
444
|
+
disabled={targetSelected.size === 0}
|
|
445
|
+
onClick={moveToSource}
|
|
446
|
+
onKeyDown={(e) => {
|
|
447
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
moveToSource();
|
|
450
|
+
}
|
|
451
|
+
}}
|
|
452
|
+
className={tkx('focus-visible:ring-2 focus-visible:ring-offset-1')}
|
|
453
|
+
style={transferButtonStyle(targetSelected.size === 0)}
|
|
454
|
+
onMouseEnter={(e) => {
|
|
455
|
+
if (targetSelected.size > 0) {
|
|
456
|
+
(e.currentTarget as HTMLElement).style.backgroundColor = theme.primary;
|
|
457
|
+
(e.currentTarget as HTMLElement).style.borderColor = theme.primary;
|
|
458
|
+
}
|
|
459
|
+
}}
|
|
460
|
+
onMouseLeave={(e) => {
|
|
461
|
+
(e.currentTarget as HTMLElement).style.backgroundColor =
|
|
462
|
+
targetSelected.size === 0 ? theme.surface : theme.surfaceAlt;
|
|
463
|
+
(e.currentTarget as HTMLElement).style.borderColor = theme.border;
|
|
464
|
+
}}
|
|
465
|
+
>
|
|
466
|
+
<ArrowLeftIcon color={theme.text} />
|
|
467
|
+
</button>
|
|
468
|
+
</div>
|
|
469
|
+
|
|
470
|
+
{/* Target list */}
|
|
471
|
+
<ListPanel
|
|
472
|
+
title={targetTitle}
|
|
473
|
+
items={targetItems}
|
|
474
|
+
selected={targetSelected}
|
|
475
|
+
onToggle={toggleTargetItem}
|
|
476
|
+
onToggleAll={toggleAllTarget}
|
|
477
|
+
search={targetSearch}
|
|
478
|
+
onSearchChange={setTargetSearch}
|
|
479
|
+
searchable={searchable}
|
|
480
|
+
height={height}
|
|
481
|
+
listId={targetListId}
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export default TkxTransferList;
|