@astryxdesign/lab 0.5.0-canary.7b60efa → 0.5.0-canary.7bd80db
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/TransferList/TransferList.d.ts +2 -2
- package/dist/TransferList/TransferList.d.ts.map +1 -1
- package/dist/TransferList/TransferList.js +128 -78
- package/dist/TransferList/TransferListSelector.d.ts +2 -2
- package/dist/TransferList/TransferListSelector.d.ts.map +1 -1
- package/dist/TransferList/TransferListSelector.js +24 -7
- package/dist/lab.css +2 -0
- package/package.json +6 -6
- package/src/TransferList/TransferList.test.tsx +9 -5
- package/src/TransferList/TransferList.tsx +123 -79
- package/src/TransferList/TransferListSelector.test.tsx +45 -2
- package/src/TransferList/TransferListSelector.tsx +34 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file TransferList.tsx
|
|
3
|
-
* @input Controlled option data, React DOM portals, local action glyphs,
|
|
3
|
+
* @input Controlled option data, React DOM portals, local action glyphs, shared Lab reorder styles, and the core i18n catalog
|
|
4
4
|
* @output Exports TransferList with immediate transfers, vertical reordering, and responsive scroll ownership
|
|
5
5
|
* @position Lab collection input; consumed by index.ts, TransferListSelector, docs, and tests
|
|
6
6
|
*
|
|
@@ -83,7 +83,7 @@ export interface TransferListProps<T extends string = string> extends Omit<BaseP
|
|
|
83
83
|
* />
|
|
84
84
|
* ```
|
|
85
85
|
*/
|
|
86
|
-
export declare function TransferList<T extends string = string>({ label, isLabelHidden, description, options, value, onChange, selectedLabel, availableLabel, hasSearch, searchLabel, searchPlaceholder, isReorderable, hasSelectAll, hasClear, renderOption, selectedEmptyText, availableEmptyText, noResultsText, xstyle, className, style, ref, role, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...restProps }: TransferListProps<T>): ReactNode;
|
|
86
|
+
export declare function TransferList<T extends string = string>({ label, isLabelHidden, description, options, value, onChange, selectedLabel: selectedLabelFromProps, availableLabel: availableLabelFromProps, hasSearch, searchLabel, searchPlaceholder: searchPlaceholderFromProps, isReorderable, hasSelectAll, hasClear, renderOption, selectedEmptyText: selectedEmptyTextFromProps, availableEmptyText: availableEmptyTextFromProps, noResultsText: noResultsTextFromProps, xstyle, className, style, ref, role, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...restProps }: TransferListProps<T>): ReactNode;
|
|
87
87
|
export declare namespace TransferList {
|
|
88
88
|
var displayName: string;
|
|
89
89
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TransferList.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferList.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAWL,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"TransferList.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferList.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAWL,KAAK,SAAS,EAEf,MAAM,OAAO,CAAC;AAGf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAoElD,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IAC3D,0DAA0D;IAC1D,KAAK,EAAE,CAAC,CAAC;IACT,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,gEAAgE;IAChE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,IAAI,CACxE,SAAS,CAAC,cAAc,CAAC,EACzB,UAAU,CACX;IACC,uCAAuC;IACvC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6DAA6D;IAC7D,OAAO,EAAE,SAAS,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1C,yEAAyE;IACzE,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;IACpB,uEAAuE;IACvE,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,CAAC;IACxC,0DAA0D;IAC1D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,+EAA+E;IAC/E,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yEAAyE;IACzE,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC;IAC5D,yCAAyC;IACzC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0CAA0C;IAC1C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,iDAAiD;IACjD,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAoND;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EACtD,KAAK,EACL,aAAqB,EACrB,WAAW,EACX,OAAO,EACP,KAAK,EACL,QAAQ,EACR,aAAa,EAAE,sBAAsB,EACrC,cAAc,EAAE,uBAAuB,EACvC,SAAiB,EACjB,WAAW,EACX,iBAAiB,EAAE,0BAA0B,EAC7C,aAAoB,EACpB,YAAoB,EACpB,QAAgB,EAChB,YAAY,EACZ,iBAAiB,EAAE,0BAA0B,EAC7C,kBAAkB,EAAE,2BAA2B,EAC/C,aAAa,EAAE,sBAAsB,EACrC,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,IAAI,EACJ,iBAAiB,EAAE,cAAc,EACjC,kBAAkB,EAAE,eAAe,EACnC,GAAG,SAAS,EACb,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,SAAS,CAo8BlC;yBA/9Be,YAAY"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file TransferList.tsx
|
|
7
|
-
* @input Controlled option data, React DOM portals, local action glyphs,
|
|
7
|
+
* @input Controlled option data, React DOM portals, local action glyphs, shared Lab reorder styles, and the core i18n catalog
|
|
8
8
|
* @output Exports TransferList with immediate transfers, vertical reordering, and responsive scroll ownership
|
|
9
9
|
* @position Lab collection input; consumed by index.ts, TransferListSelector, docs, and tests
|
|
10
10
|
*
|
|
@@ -26,25 +26,28 @@ import { Text } from '@astryxdesign/core/Text';
|
|
|
26
26
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
27
27
|
import { VisuallyHidden } from '@astryxdesign/core/VisuallyHidden';
|
|
28
28
|
import { useAnnounce } from '@astryxdesign/core/hooks';
|
|
29
|
+
import { useTranslator } from '@astryxdesign/core/i18n';
|
|
29
30
|
import '@astryxdesign/core/theme/tokens.stylex';
|
|
30
|
-
import { borderVars, colorVars, spacingVars, typeScaleVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
31
|
+
import { borderVars, colorVars, sizeVars, spacingVars, typeScaleVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
31
32
|
import { focusOutlineStyles, mergeProps, mergeRefs, themeProps } from '@astryxdesign/core/utils';
|
|
32
33
|
import { reorderStyles } from "../reorderStyles.js";
|
|
33
34
|
import "./tokens.stylex.js";
|
|
34
35
|
import { transferListVars } from "./tokens.stylex.js";
|
|
36
|
+
|
|
37
|
+
// Lucide glyphs the shared icon registry does not carry (there is no `add` or
|
|
38
|
+
// `grip` semantic name). Typed as icon components and rendered through `Icon`
|
|
39
|
+
// so sizing, colour and a11y come from the icon pipeline rather than a raw
|
|
40
|
+
// inline `<svg>`. Removal reuses the registry `close` glyph instead.
|
|
35
41
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
36
|
-
function PlusIcon() {
|
|
42
|
+
function PlusIcon(props) {
|
|
37
43
|
return /*#__PURE__*/_jsxs("svg", {
|
|
38
|
-
className: "lucide lucide-plus",
|
|
39
|
-
width: 16,
|
|
40
|
-
height: 16,
|
|
41
44
|
viewBox: "0 0 24 24",
|
|
42
45
|
fill: "none",
|
|
43
46
|
stroke: "currentColor",
|
|
44
47
|
strokeWidth: 1.5,
|
|
45
48
|
strokeLinecap: "round",
|
|
46
49
|
strokeLinejoin: "round",
|
|
47
|
-
|
|
50
|
+
...props,
|
|
48
51
|
children: [/*#__PURE__*/_jsx("path", {
|
|
49
52
|
d: "M5 12h14"
|
|
50
53
|
}), /*#__PURE__*/_jsx("path", {
|
|
@@ -52,37 +55,15 @@ function PlusIcon() {
|
|
|
52
55
|
})]
|
|
53
56
|
});
|
|
54
57
|
}
|
|
55
|
-
function
|
|
56
|
-
return /*#__PURE__*/_jsxs("svg", {
|
|
57
|
-
className: "lucide lucide-x",
|
|
58
|
-
width: 16,
|
|
59
|
-
height: 16,
|
|
60
|
-
viewBox: "0 0 24 24",
|
|
61
|
-
fill: "none",
|
|
62
|
-
stroke: "currentColor",
|
|
63
|
-
strokeWidth: 1.5,
|
|
64
|
-
strokeLinecap: "round",
|
|
65
|
-
strokeLinejoin: "round",
|
|
66
|
-
"aria-hidden": true,
|
|
67
|
-
children: [/*#__PURE__*/_jsx("path", {
|
|
68
|
-
d: "M18 6 6 18"
|
|
69
|
-
}), /*#__PURE__*/_jsx("path", {
|
|
70
|
-
d: "m6 6 12 12"
|
|
71
|
-
})]
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
function GripVerticalIcon() {
|
|
58
|
+
function GripVerticalIcon(props) {
|
|
75
59
|
return /*#__PURE__*/_jsxs("svg", {
|
|
76
|
-
className: "lucide lucide-grip-vertical",
|
|
77
|
-
width: 16,
|
|
78
|
-
height: 16,
|
|
79
60
|
viewBox: "0 0 24 24",
|
|
80
61
|
fill: "none",
|
|
81
62
|
stroke: "currentColor",
|
|
82
63
|
strokeWidth: 1.5,
|
|
83
64
|
strokeLinecap: "round",
|
|
84
65
|
strokeLinejoin: "round",
|
|
85
|
-
|
|
66
|
+
...props,
|
|
86
67
|
children: [/*#__PURE__*/_jsx("circle", {
|
|
87
68
|
cx: "9",
|
|
88
69
|
cy: "5",
|
|
@@ -143,6 +124,7 @@ const styles = {
|
|
|
143
124
|
},
|
|
144
125
|
headerAction: {
|
|
145
126
|
kZKoxP: "xt7dq6l",
|
|
127
|
+
kAzted: "x1gdqauq",
|
|
146
128
|
k8WAf4: "xt970qd",
|
|
147
129
|
kg3NbH: "xnjsko4",
|
|
148
130
|
kaIpWk: "x2u8bby",
|
|
@@ -186,15 +168,16 @@ function moveItem(value, fromIndex, toIndex) {
|
|
|
186
168
|
nextValue.splice(toIndex, 0, item);
|
|
187
169
|
return nextValue;
|
|
188
170
|
}
|
|
171
|
+
|
|
172
|
+
// Case folding is locale-independent here, matching Selector, MultiSelector,
|
|
173
|
+
// and CommandPalette. Locale-sensitive folding would mean raw Intl access,
|
|
174
|
+
// which the shipped packages route through the provider instead.
|
|
189
175
|
function matchesQuery(option, query) {
|
|
190
176
|
if (query === '') {
|
|
191
177
|
return true;
|
|
192
178
|
}
|
|
193
179
|
const description = typeof option.description === 'string' ? option.description : '';
|
|
194
|
-
return [option.label, description, option.group ?? ''].some(part => part.
|
|
195
|
-
}
|
|
196
|
-
function itemCount(count) {
|
|
197
|
-
return `${count} ${count === 1 ? 'item' : 'items'}`;
|
|
180
|
+
return [option.label, description, option.group ?? ''].some(part => part.toLowerCase().includes(query));
|
|
198
181
|
}
|
|
199
182
|
|
|
200
183
|
/**
|
|
@@ -220,18 +203,18 @@ export function TransferList({
|
|
|
220
203
|
options,
|
|
221
204
|
value,
|
|
222
205
|
onChange,
|
|
223
|
-
selectedLabel
|
|
224
|
-
availableLabel
|
|
206
|
+
selectedLabel: selectedLabelFromProps,
|
|
207
|
+
availableLabel: availableLabelFromProps,
|
|
225
208
|
hasSearch = false,
|
|
226
209
|
searchLabel,
|
|
227
|
-
searchPlaceholder
|
|
210
|
+
searchPlaceholder: searchPlaceholderFromProps,
|
|
228
211
|
isReorderable = true,
|
|
229
212
|
hasSelectAll = false,
|
|
230
213
|
hasClear = false,
|
|
231
214
|
renderOption,
|
|
232
|
-
selectedEmptyText
|
|
233
|
-
availableEmptyText
|
|
234
|
-
noResultsText
|
|
215
|
+
selectedEmptyText: selectedEmptyTextFromProps,
|
|
216
|
+
availableEmptyText: availableEmptyTextFromProps,
|
|
217
|
+
noResultsText: noResultsTextFromProps,
|
|
235
218
|
xstyle,
|
|
236
219
|
className,
|
|
237
220
|
style,
|
|
@@ -241,6 +224,13 @@ export function TransferList({
|
|
|
241
224
|
'aria-describedby': ariaDescribedBy,
|
|
242
225
|
...restProps
|
|
243
226
|
}) {
|
|
227
|
+
const t = useTranslator();
|
|
228
|
+
const selectedLabel = selectedLabelFromProps ?? t('@astryx.transferList.selectedLabel');
|
|
229
|
+
const availableLabel = availableLabelFromProps ?? t('@astryx.transferList.availableLabel');
|
|
230
|
+
const searchPlaceholder = searchPlaceholderFromProps ?? t('@astryx.transferList.searchPlaceholder');
|
|
231
|
+
const selectedEmptyText = selectedEmptyTextFromProps ?? t('@astryx.transferList.selectedEmpty');
|
|
232
|
+
const availableEmptyText = availableEmptyTextFromProps ?? t('@astryx.transferList.availableEmpty');
|
|
233
|
+
const noResultsText = noResultsTextFromProps ?? t('@astryx.transferList.noResults');
|
|
244
234
|
const labelId = useId();
|
|
245
235
|
const descriptionId = useId();
|
|
246
236
|
const selectedHeadingId = useId();
|
|
@@ -276,7 +266,7 @@ export function TransferList({
|
|
|
276
266
|
return map;
|
|
277
267
|
}, [options]);
|
|
278
268
|
const selectedValues = useMemo(() => new Set(value), [value]);
|
|
279
|
-
const normalizedQuery = query.trim().
|
|
269
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
280
270
|
const selectedOptions = useMemo(() => value.map(optionValue => optionByValue.get(optionValue)).filter(option => option != null && matchesQuery(option, normalizedQuery)), [normalizedQuery, optionByValue, value]);
|
|
281
271
|
const availableOptions = useMemo(() => options.filter(option => !selectedValues.has(option.value) && matchesQuery(option, normalizedQuery)), [normalizedQuery, options, selectedValues]);
|
|
282
272
|
const groupedAvailable = useMemo(() => {
|
|
@@ -371,25 +361,33 @@ export function TransferList({
|
|
|
371
361
|
}
|
|
372
362
|
const nextValue = [...currentValueRef.current, option.value];
|
|
373
363
|
commit(nextValue);
|
|
374
|
-
announce(
|
|
364
|
+
announce(t('@astryx.transferList.announceAdded', {
|
|
365
|
+
label: option.label,
|
|
366
|
+
count: nextValue.length
|
|
367
|
+
}));
|
|
375
368
|
focusAfterTransfer('available', index);
|
|
376
|
-
}, [announce, commit, focusAfterTransfer]);
|
|
369
|
+
}, [announce, commit, focusAfterTransfer, t]);
|
|
377
370
|
const removeOption = useCallback((option, index) => {
|
|
378
371
|
if (option.isTransferDisabled) {
|
|
379
372
|
return;
|
|
380
373
|
}
|
|
381
374
|
const nextValue = currentValueRef.current.filter(item => item !== option.value);
|
|
382
375
|
commit(nextValue);
|
|
383
|
-
announce(
|
|
376
|
+
announce(t('@astryx.transferList.announceRemoved', {
|
|
377
|
+
label: option.label,
|
|
378
|
+
count: nextValue.length
|
|
379
|
+
}));
|
|
384
380
|
focusAfterTransfer('selected', index);
|
|
385
|
-
}, [announce, commit, focusAfterTransfer]);
|
|
381
|
+
}, [announce, commit, focusAfterTransfer, t]);
|
|
386
382
|
const addAll = useCallback(() => {
|
|
387
383
|
const additions = options.filter(option => !option.isTransferDisabled && !currentValueRef.current.includes(option.value)).map(option => option.value);
|
|
388
384
|
if (additions.length > 0) {
|
|
389
385
|
commit([...currentValueRef.current, ...additions]);
|
|
390
|
-
announce(
|
|
386
|
+
announce(t('@astryx.transferList.announceBulkAdded', {
|
|
387
|
+
count: additions.length
|
|
388
|
+
}));
|
|
391
389
|
}
|
|
392
|
-
}, [announce, commit, options]);
|
|
390
|
+
}, [announce, commit, options, t]);
|
|
393
391
|
const clearSelected = useCallback(() => {
|
|
394
392
|
const nextValue = currentValueRef.current.filter(optionValue => {
|
|
395
393
|
const option = optionByValue.get(optionValue);
|
|
@@ -398,9 +396,11 @@ export function TransferList({
|
|
|
398
396
|
const removed = currentValueRef.current.length - nextValue.length;
|
|
399
397
|
if (removed > 0) {
|
|
400
398
|
commit(nextValue);
|
|
401
|
-
announce(
|
|
399
|
+
announce(t('@astryx.transferList.announceBulkRemoved', {
|
|
400
|
+
count: removed
|
|
401
|
+
}));
|
|
402
402
|
}
|
|
403
|
-
}, [announce, commit, optionByValue]);
|
|
403
|
+
}, [announce, commit, optionByValue, t]);
|
|
404
404
|
const movableRange = useCallback((optionValue, orderedValue) => {
|
|
405
405
|
const index = orderedValue.indexOf(optionValue);
|
|
406
406
|
let start = 0;
|
|
@@ -463,9 +463,13 @@ export function TransferList({
|
|
|
463
463
|
hasPointerMoved: false
|
|
464
464
|
});
|
|
465
465
|
if (mode === 'keyboard') {
|
|
466
|
-
announce(
|
|
466
|
+
announce(t('@astryx.transferList.announceGrabbed', {
|
|
467
|
+
label: option.label,
|
|
468
|
+
position: index + 1,
|
|
469
|
+
total: currentValueRef.current.length
|
|
470
|
+
}));
|
|
467
471
|
}
|
|
468
|
-
}, [announce, isReorderable, setReorderSession]);
|
|
472
|
+
}, [announce, isReorderable, setReorderSession, t]);
|
|
469
473
|
const finishReorder = useCallback(cancelled => {
|
|
470
474
|
const session = reorderSessionRef.current;
|
|
471
475
|
if (!session) {
|
|
@@ -475,7 +479,9 @@ export function TransferList({
|
|
|
475
479
|
if (cancelled || !session.hasPointerMoved) {
|
|
476
480
|
setReorderSession(null);
|
|
477
481
|
if (cancelled) {
|
|
478
|
-
announce(
|
|
482
|
+
announce(t('@astryx.transferList.announceMoveCancelled', {
|
|
483
|
+
label: session.label
|
|
484
|
+
}));
|
|
479
485
|
}
|
|
480
486
|
return;
|
|
481
487
|
}
|
|
@@ -484,21 +490,34 @@ export function TransferList({
|
|
|
484
490
|
flushSync(() => setReorderSession(null));
|
|
485
491
|
if (hasChanged) {
|
|
486
492
|
commit(nextValue);
|
|
487
|
-
announce(
|
|
493
|
+
announce(t('@astryx.transferList.announceDropped', {
|
|
494
|
+
label: session.label,
|
|
495
|
+
position: session.toIndex + 1,
|
|
496
|
+
total: session.originalValue.length
|
|
497
|
+
}));
|
|
488
498
|
} else {
|
|
489
|
-
announce(
|
|
499
|
+
announce(t('@astryx.transferList.announceReturned', {
|
|
500
|
+
label: session.label,
|
|
501
|
+
position: session.fromIndex + 1
|
|
502
|
+
}));
|
|
490
503
|
}
|
|
491
504
|
return;
|
|
492
505
|
}
|
|
493
506
|
if (cancelled) {
|
|
494
507
|
commit(session.originalValue);
|
|
495
|
-
announce(
|
|
508
|
+
announce(t('@astryx.transferList.announceMoveCancelled', {
|
|
509
|
+
label: session.label
|
|
510
|
+
}));
|
|
496
511
|
} else {
|
|
497
512
|
const index = currentValueRef.current.indexOf(session.value);
|
|
498
|
-
announce(
|
|
513
|
+
announce(t('@astryx.transferList.announceDropped', {
|
|
514
|
+
label: session.label,
|
|
515
|
+
position: index + 1,
|
|
516
|
+
total: currentValueRef.current.length
|
|
517
|
+
}));
|
|
499
518
|
}
|
|
500
519
|
setReorderSession(null);
|
|
501
|
-
}, [announce, commit, setReorderSession]);
|
|
520
|
+
}, [announce, commit, setReorderSession, t]);
|
|
502
521
|
const handleReorderClick = useCallback(option => {
|
|
503
522
|
if (suppressClickRef.current) {
|
|
504
523
|
suppressClickRef.current = false;
|
|
@@ -543,9 +562,13 @@ export function TransferList({
|
|
|
543
562
|
event.preventDefault();
|
|
544
563
|
if (moveOption(option.value, target)) {
|
|
545
564
|
const nextIndex = currentValueRef.current.indexOf(option.value);
|
|
546
|
-
announce(
|
|
565
|
+
announce(t('@astryx.transferList.announceMovedToPosition', {
|
|
566
|
+
label: option.label,
|
|
567
|
+
position: nextIndex + 1,
|
|
568
|
+
total: currentValueRef.current.length
|
|
569
|
+
}));
|
|
547
570
|
}
|
|
548
|
-
}, [announce, finishReorder, movableRange, moveOption]);
|
|
571
|
+
}, [announce, finishReorder, movableRange, moveOption, t]);
|
|
549
572
|
const handlePointerDown = useCallback((event, option) => {
|
|
550
573
|
if (event.button !== 0 || option.isReorderDisabled) {
|
|
551
574
|
return;
|
|
@@ -608,9 +631,13 @@ export function TransferList({
|
|
|
608
631
|
};
|
|
609
632
|
setReorderSession(nextSession);
|
|
610
633
|
if (hasCrossedThreshold && session.toIndex !== targetIndex) {
|
|
611
|
-
announce(
|
|
634
|
+
announce(t('@astryx.transferList.announceMovedToPosition', {
|
|
635
|
+
label: option.label,
|
|
636
|
+
position: targetIndex + 1,
|
|
637
|
+
total: session.originalValue.length
|
|
638
|
+
}));
|
|
612
639
|
}
|
|
613
|
-
}, [announce, movableRange, setReorderSession]);
|
|
640
|
+
}, [announce, movableRange, setReorderSession, t]);
|
|
614
641
|
const handlePointerEnd = useCallback((event, cancelled) => {
|
|
615
642
|
const session = reorderSessionRef.current;
|
|
616
643
|
if (session?.mode !== 'pointer' || session.pointerId !== event.pointerId) {
|
|
@@ -630,21 +657,30 @@ export function TransferList({
|
|
|
630
657
|
}, [finishReorder]);
|
|
631
658
|
const handleSearch = useCallback(nextQuery => {
|
|
632
659
|
setQuery(nextQuery);
|
|
633
|
-
const normalized = nextQuery.trim().
|
|
660
|
+
const normalized = nextQuery.trim().toLowerCase();
|
|
634
661
|
if (normalized === '') {
|
|
635
662
|
announce('');
|
|
636
663
|
} else {
|
|
637
664
|
const count = options.filter(option => matchesQuery(option, normalized)).length;
|
|
638
|
-
announce(
|
|
665
|
+
announce(t('@astryx.transferList.announceSearchResults', {
|
|
666
|
+
count
|
|
667
|
+
}));
|
|
639
668
|
}
|
|
640
|
-
}, [announce, options]);
|
|
669
|
+
}, [announce, options, t]);
|
|
641
670
|
const optionActions = (option, side, index) => {
|
|
642
671
|
const isTransferDisabled = option.isTransferDisabled === true;
|
|
643
|
-
const disabledReason = option.disabledMessage ??
|
|
672
|
+
const disabledReason = option.disabledMessage ?? t('@astryx.transferList.transferDisabled', {
|
|
673
|
+
label: option.label
|
|
674
|
+
});
|
|
644
675
|
if (side === 'available') {
|
|
645
676
|
return /*#__PURE__*/_jsx(IconButton, {
|
|
646
|
-
label:
|
|
647
|
-
|
|
677
|
+
label: t('@astryx.transferList.addOption', {
|
|
678
|
+
label: option.label
|
|
679
|
+
}),
|
|
680
|
+
icon: /*#__PURE__*/_jsx(Icon, {
|
|
681
|
+
icon: PlusIcon,
|
|
682
|
+
size: "sm"
|
|
683
|
+
}),
|
|
648
684
|
size: "sm",
|
|
649
685
|
variant: "ghost",
|
|
650
686
|
isDisabled: isTransferDisabled,
|
|
@@ -654,8 +690,13 @@ export function TransferList({
|
|
|
654
690
|
});
|
|
655
691
|
}
|
|
656
692
|
return /*#__PURE__*/_jsx(IconButton, {
|
|
657
|
-
label:
|
|
658
|
-
|
|
693
|
+
label: t('@astryx.transferList.removeOption', {
|
|
694
|
+
label: option.label
|
|
695
|
+
}),
|
|
696
|
+
icon: /*#__PURE__*/_jsx(Icon, {
|
|
697
|
+
icon: "close",
|
|
698
|
+
size: "sm"
|
|
699
|
+
}),
|
|
659
700
|
size: "sm",
|
|
660
701
|
variant: "ghost",
|
|
661
702
|
isDisabled: isTransferDisabled,
|
|
@@ -687,12 +728,19 @@ export function TransferList({
|
|
|
687
728
|
}
|
|
688
729
|
const active = reorderSession?.value === option.value;
|
|
689
730
|
const isReorderDisabled = option.isReorderDisabled === true;
|
|
690
|
-
const disabledReason = option.disabledMessage ??
|
|
731
|
+
const disabledReason = option.disabledMessage ?? t('@astryx.transferList.reorderDisabled', {
|
|
732
|
+
label: option.label
|
|
733
|
+
});
|
|
691
734
|
return /*#__PURE__*/_jsx(IconButton, {
|
|
692
|
-
label:
|
|
735
|
+
label: t('@astryx.transferList.reorderOption', {
|
|
736
|
+
label: option.label
|
|
737
|
+
}),
|
|
693
738
|
"aria-describedby": reorderInstructionsId,
|
|
694
739
|
"aria-pressed": active,
|
|
695
|
-
icon: /*#__PURE__*/_jsx(
|
|
740
|
+
icon: /*#__PURE__*/_jsx(Icon, {
|
|
741
|
+
icon: GripVerticalIcon,
|
|
742
|
+
size: "sm"
|
|
743
|
+
}),
|
|
696
744
|
size: "sm",
|
|
697
745
|
variant: "ghost",
|
|
698
746
|
isDisabled: isReorderDisabled,
|
|
@@ -778,7 +826,9 @@ export function TransferList({
|
|
|
778
826
|
children: /*#__PURE__*/_jsx(TextInput, {
|
|
779
827
|
ref: searchRef,
|
|
780
828
|
role: "searchbox",
|
|
781
|
-
label: searchLabel ??
|
|
829
|
+
label: searchLabel ?? t('@astryx.transferList.searchLabel', {
|
|
830
|
+
label
|
|
831
|
+
}),
|
|
782
832
|
isLabelHidden: true,
|
|
783
833
|
value: query,
|
|
784
834
|
placeholder: searchPlaceholder,
|
|
@@ -794,7 +844,7 @@ export function TransferList({
|
|
|
794
844
|
})
|
|
795
845
|
}), /*#__PURE__*/_jsx(VisuallyHidden, {
|
|
796
846
|
id: reorderInstructionsId,
|
|
797
|
-
children:
|
|
847
|
+
children: t('@astryx.transferList.reorderInstructions')
|
|
798
848
|
}), /*#__PURE__*/_jsxs("div", {
|
|
799
849
|
...mergeProps(themeProps('transfer-list-collection'), {
|
|
800
850
|
className: "xrvj5dj xcjjqqt x1sp3fdr xxhr3t xeuugli"
|
|
@@ -817,7 +867,7 @@ export function TransferList({
|
|
|
817
867
|
color: "secondary",
|
|
818
868
|
children: selectedLabel
|
|
819
869
|
}), hasClear && /*#__PURE__*/_jsx(Button, {
|
|
820
|
-
label:
|
|
870
|
+
label: t('@astryx.transferList.clear'),
|
|
821
871
|
size: "sm",
|
|
822
872
|
variant: "ghost",
|
|
823
873
|
xstyle: styles.headerAction,
|
|
@@ -866,7 +916,7 @@ export function TransferList({
|
|
|
866
916
|
color: "secondary",
|
|
867
917
|
children: availableLabel
|
|
868
918
|
}), hasSelectAll && /*#__PURE__*/_jsx(Button, {
|
|
869
|
-
label:
|
|
919
|
+
label: t('@astryx.transferList.addAll'),
|
|
870
920
|
size: "sm",
|
|
871
921
|
variant: "ghost",
|
|
872
922
|
xstyle: styles.headerAction,
|
|
@@ -894,7 +944,7 @@ export function TransferList({
|
|
|
894
944
|
type: "body",
|
|
895
945
|
weight: "bold",
|
|
896
946
|
color: "primary",
|
|
897
|
-
children: group || '
|
|
947
|
+
children: group || t('@astryx.transferList.ungroupedLabel')
|
|
898
948
|
})
|
|
899
949
|
}), groupOptions.map(option => transferItem(option, 'available', availableOptions.indexOf(option)))]
|
|
900
950
|
}, group || 'ungrouped'))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file TransferListSelector.tsx
|
|
3
|
-
* @input Controlled option data, uncontrolled ComplexSelector state, TransferList, and
|
|
3
|
+
* @input Controlled option data, uncontrolled ComplexSelector state, TransferList, action controls, and the core i18n catalog
|
|
4
4
|
* @output Exports the immediate-or-staged TransferListSelector composition with draft cleanup after dismissal
|
|
5
5
|
* @position Lab collection input shell; consumed by the TransferList entry point, docs, and tests
|
|
6
6
|
*
|
|
@@ -49,7 +49,7 @@ export type TransferListSelectorProps<T extends string = string> = Pick<ComplexS
|
|
|
49
49
|
* />
|
|
50
50
|
* ```
|
|
51
51
|
*/
|
|
52
|
-
export declare function TransferListSelector<T extends string = string>({ label, description, options, value, onChange, changeAction, commitBehavior, triggerLabel, isDisabled, isLoading, width, applyLabel, cancelLabel, selectedLabel, availableLabel, hasSearch, searchLabel, searchPlaceholder, isReorderable, hasSelectAll, hasClear, renderOption, selectedEmptyText, availableEmptyText, noResultsText, ...selectorProps }: TransferListSelectorProps<T>): ReactNode;
|
|
52
|
+
export declare function TransferListSelector<T extends string = string>({ label, description, options, value, onChange, changeAction, commitBehavior, triggerLabel, isDisabled, isLoading, width, applyLabel: applyLabelFromProps, cancelLabel: cancelLabelFromProps, selectedLabel, availableLabel, hasSearch, searchLabel, searchPlaceholder, isReorderable, hasSelectAll, hasClear, renderOption, selectedEmptyText, availableEmptyText, noResultsText, ...selectorProps }: TransferListSelectorProps<T>): ReactNode;
|
|
53
53
|
export declare namespace TransferListSelector {
|
|
54
54
|
var displayName: string;
|
|
55
55
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TransferListSelector.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferListSelector.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAG/E,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"TransferListSelector.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferListSelector.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAG/E,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,oCAAoC,CAAC;AAI5C,OAAO,EAAe,KAAK,iBAAiB,EAAC,MAAM,gBAAgB,CAAC;AAsDpE,KAAK,6BAA6B,GAC9B,OAAO,GACP,cAAc,GACd,eAAe,GACf,aAAa,GACb,YAAY,GACZ,YAAY,GACZ,YAAY,GACZ,WAAW,GACX,QAAQ,GACR,eAAe,GACf,cAAc,GACd,MAAM,GACN,OAAO,GACP,WAAW,GACX,QAAQ,GACR,WAAW,GACX,OAAO,GACP,aAAa,CAAC;AAElB,KAAK,uBAAuB,GACxB,SAAS,GACT,eAAe,GACf,gBAAgB,GAChB,WAAW,GACX,aAAa,GACb,mBAAmB,GACnB,eAAe,GACf,cAAc,GACd,UAAU,GACV,cAAc,GACd,mBAAmB,GACnB,oBAAoB,GACpB,eAAe,CAAC;AAEpB,MAAM,MAAM,kCAAkC,GAAG,WAAW,GAAG,QAAQ,CAAC;AAExE,MAAM,MAAM,yBAAyB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI,IAAI,CACrE,oBAAoB,CAAC,SAAS,CAAC,EAAE,CAAC,EAClC,6BAA6B,CAC9B,GACC,IAAI,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,uBAAuB,CAAC,GAAG;IACpD,mDAAmD;IACnD,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;IACpB,4CAA4C;IAC5C,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,CAAC;IACxC,8DAA8D;IAC9D,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D;;;OAGG;IACH,cAAc,CAAC,EAAE,kCAAkC,CAAC;IACpD,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2FAA2F;IAC3F,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AA+BJ;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EAC9D,KAAK,EACL,WAAW,EACX,OAAO,EACP,KAAK,EACL,QAAQ,EACR,YAAY,EACZ,cAA4B,EAC5B,YAAY,EACZ,UAAU,EACV,SAAS,EACT,KAAK,EACL,UAAU,EAAE,mBAAmB,EAC/B,WAAW,EAAE,oBAAoB,EACjC,aAAa,EACb,cAAc,EACd,SAAS,EACT,WAAW,EACX,iBAAiB,EACjB,aAAa,EACb,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,EACb,GAAG,aAAa,EACjB,EAAE,yBAAyB,CAAC,CAAC,CAAC,GAAG,SAAS,CAqI1C;yBAhKe,oBAAoB"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file TransferListSelector.tsx
|
|
7
|
-
* @input Controlled option data, uncontrolled ComplexSelector state, TransferList, and
|
|
7
|
+
* @input Controlled option data, uncontrolled ComplexSelector state, TransferList, action controls, and the core i18n catalog
|
|
8
8
|
* @output Exports the immediate-or-staged TransferListSelector composition with draft cleanup after dismissal
|
|
9
9
|
* @position Lab collection input shell; consumed by the TransferList entry point, docs, and tests
|
|
10
10
|
*
|
|
@@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
17
17
|
import * as stylex from '@stylexjs/stylex';
|
|
18
18
|
import { Button } from '@astryxdesign/core/Button';
|
|
19
19
|
import { ComplexSelector } from '@astryxdesign/core/ComplexSelector';
|
|
20
|
+
import { useTranslator } from '@astryxdesign/core/i18n';
|
|
20
21
|
import { HStack } from '@astryxdesign/core/Stack';
|
|
21
22
|
import '@astryxdesign/core/theme/tokens.stylex';
|
|
22
23
|
import { borderVars, colorVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
@@ -94,8 +95,8 @@ export function TransferListSelector({
|
|
|
94
95
|
isDisabled,
|
|
95
96
|
isLoading,
|
|
96
97
|
width,
|
|
97
|
-
applyLabel
|
|
98
|
-
cancelLabel
|
|
98
|
+
applyLabel: applyLabelFromProps,
|
|
99
|
+
cancelLabel: cancelLabelFromProps,
|
|
99
100
|
selectedLabel,
|
|
100
101
|
availableLabel,
|
|
101
102
|
hasSearch,
|
|
@@ -110,6 +111,9 @@ export function TransferListSelector({
|
|
|
110
111
|
noResultsText,
|
|
111
112
|
...selectorProps
|
|
112
113
|
}) {
|
|
114
|
+
const t = useTranslator();
|
|
115
|
+
const applyLabel = applyLabelFromProps ?? t('@astryx.transferListSelector.apply');
|
|
116
|
+
const cancelLabel = cancelLabelFromProps ?? t('@astryx.transferListSelector.cancel');
|
|
113
117
|
const [draftValue, setDraftValue] = useState(value);
|
|
114
118
|
const previousAppliedValueRef = useRef([...value]);
|
|
115
119
|
const previousCommitBehaviorRef = useRef(commitBehavior);
|
|
@@ -137,7 +141,9 @@ export function TransferListSelector({
|
|
|
137
141
|
value: value,
|
|
138
142
|
onChange: onChange,
|
|
139
143
|
changeAction: changeAction,
|
|
140
|
-
triggerLabel: triggerLabel ??
|
|
144
|
+
triggerLabel: triggerLabel ?? t('@astryx.transferListSelector.triggerLabel', {
|
|
145
|
+
count: value.length
|
|
146
|
+
}),
|
|
141
147
|
isDisabled: isDisabled,
|
|
142
148
|
isLoading: isLoading,
|
|
143
149
|
width: resolvedWidth,
|
|
@@ -154,16 +160,27 @@ export function TransferListSelector({
|
|
|
154
160
|
isOpen: state.isOpen,
|
|
155
161
|
resetDraft: resetDraft
|
|
156
162
|
}), /*#__PURE__*/_jsx("fieldset", {
|
|
157
|
-
disabled: isDisabled
|
|
163
|
+
disabled: isDisabled,
|
|
158
164
|
...{
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
0: {
|
|
166
|
+
className: "x1iyjqo2 xs83m0k xdl72j9 xeuugli x2lwn1j x1ghz6dp x1717udv xc342km xng3xce x1odjw0f xish69e"
|
|
167
|
+
},
|
|
168
|
+
1: {
|
|
169
|
+
className: "x1iyjqo2 xs83m0k xdl72j9 xeuugli x2lwn1j x1ghz6dp x1717udv xc342km xng3xce x1odjw0f xish69e x19v85br x16khyan"
|
|
170
|
+
}
|
|
171
|
+
}[!!state.isBusy << 0],
|
|
161
172
|
children: /*#__PURE__*/_jsx(TransferList, {
|
|
162
173
|
label: label,
|
|
163
174
|
isLabelHidden: true,
|
|
164
175
|
options: options,
|
|
165
176
|
value: transferListValue,
|
|
166
177
|
onChange: nextValue => {
|
|
178
|
+
// Now that busy no longer disables the fieldset, the controls
|
|
179
|
+
// stay live while a change is in flight; ignore transfers
|
|
180
|
+
// until it settles so one pending action cannot be stacked.
|
|
181
|
+
if (state.isBusy) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
167
184
|
if (isStaged) {
|
|
168
185
|
setDraftValue(nextValue);
|
|
169
186
|
} else {
|
package/dist/lab.css
CHANGED
|
@@ -301,6 +301,7 @@
|
|
|
301
301
|
.x1jm3nie:not(#\#):not(#\#):not(#\#){cursor:grab}
|
|
302
302
|
.xi9pz9s:not(#\#):not(#\#):not(#\#){cursor:grabbing}
|
|
303
303
|
.x1ypdohk:not(#\#):not(#\#):not(#\#){cursor:pointer}
|
|
304
|
+
.x19v85br:not(#\#):not(#\#):not(#\#){cursor:progress}
|
|
304
305
|
.x1lliihq:not(#\#):not(#\#):not(#\#){display:block}
|
|
305
306
|
.xjp7ctv:not(#\#):not(#\#):not(#\#){display:contents}
|
|
306
307
|
.x78zum5:not(#\#):not(#\#):not(#\#){display:flex}
|
|
@@ -534,6 +535,7 @@
|
|
|
534
535
|
.xjwf9q1:not(#\#):not(#\#):not(#\#):not(#\#){min-height:24px}
|
|
535
536
|
.x1cy360x:not(#\#):not(#\#):not(#\#):not(#\#){min-height:3em}
|
|
536
537
|
.x1uogy3y:not(#\#):not(#\#):not(#\#):not(#\#){min-height:var(--size-element-md)}
|
|
538
|
+
.x1gdqauq:not(#\#):not(#\#):not(#\#):not(#\#){min-height:var(--size-element-sm)}
|
|
537
539
|
.xu95rz8:not(#\#):not(#\#):not(#\#):not(#\#){min-height:var(--transfer-list-panel-min-block-size)}
|
|
538
540
|
.x82snj4:not(#\#):not(#\#):not(#\#):not(#\#){min-height:var(--x-minHeight)}
|
|
539
541
|
.xeuugli:not(#\#):not(#\#):not(#\#):not(#\#){min-width:0}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/lab",
|
|
3
|
-
"version": "0.5.0-canary.
|
|
3
|
+
"version": "0.5.0-canary.7bd80db",
|
|
4
4
|
"description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
|
|
5
5
|
"author": "Meta Open Source",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"canaryOnly": true,
|
|
24
24
|
"_comment": "Published ONLY to the @canary dist-tag (see .github/workflows/release.yml). Never published as a stable `latest` release. `private: true` is intentional and must stay — it is npm's hard guarantee against an accidental stable publish."
|
|
25
25
|
},
|
|
26
|
+
"type": "module",
|
|
26
27
|
"main": "./dist/index.js",
|
|
27
|
-
"module": "./dist/index.js",
|
|
28
28
|
"types": "./dist/index.d.ts",
|
|
29
29
|
"sideEffects": false,
|
|
30
30
|
"files": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@stylexjs/stylex": ">=0.10.0",
|
|
48
|
-
"@astryxdesign/core": "0.5.0-canary.
|
|
48
|
+
"@astryxdesign/core": "0.5.0-canary.7bd80db",
|
|
49
49
|
"react": ">=19.0.0",
|
|
50
50
|
"react-dom": ">=19.0.0"
|
|
51
51
|
},
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"d3-shape": "^3.2.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@astryxdesign/cli": "0.5.0-canary.
|
|
58
|
+
"@astryxdesign/cli": "0.5.0-canary.7bd80db",
|
|
59
59
|
"@types/d3-array": "^3.2.2",
|
|
60
60
|
"@types/d3-scale": "^4.0.9",
|
|
61
61
|
"@types/d3-shape": "^3.1.8",
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"build": "rimraf dist && pnpm build:esm && tsc --project tsconfig.build.json && pnpm build:css && node ../../scripts/check-no-dev-jsx.mjs && node ../../scripts/check-fully-specified.mjs",
|
|
66
|
-
"build:esm": "babel src --out-dir dist --extensions .ts,.tsx --out-file-extension .js --ignore \"**/*.test.ts,**/*.test.tsx,**/__tests__/**,**/*.perf.test.*,**/*.doc.mjs,**/*.d.ts,**/*.css,**/*.css.d.ts\" --config-file ./babel.config.
|
|
66
|
+
"build:esm": "babel src --out-dir dist --extensions .ts,.tsx --out-file-extension .js --ignore \"**/*.test.ts,**/*.test.tsx,**/__tests__/**,**/*.perf.test.*,**/*.doc.mjs,**/*.d.ts,**/*.css,**/*.css.d.ts\" --config-file ./babel.config.json",
|
|
67
67
|
"build:css": "node ../../scripts/build-css.mjs --package lab",
|
|
68
|
-
"dev": "babel src --watch --out-dir dist --extensions .ts,.tsx --out-file-extension .js --ignore \"**/*.test.*,**/*.doc.mjs,**/*.d.ts,**/*.css*\" --config-file ./babel.config.
|
|
68
|
+
"dev": "babel src --watch --out-dir dist --extensions .ts,.tsx --out-file-extension .js --ignore \"**/*.test.*,**/*.doc.mjs,**/*.d.ts,**/*.css*\" --config-file ./babel.config.json",
|
|
69
69
|
"test": "vitest run --root ../..",
|
|
70
70
|
"test:watch": "vitest --root ../..",
|
|
71
71
|
"lint": "eslint src",
|
|
@@ -187,8 +187,14 @@ describe('TransferList', () => {
|
|
|
187
187
|
);
|
|
188
188
|
const addButton = screen.getByRole('button', {name: 'Add Owner'});
|
|
189
189
|
const removeButton = screen.getByRole('button', {name: 'Remove Name'});
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
// Glyphs render through the shared Icon primitive rather than a raw
|
|
191
|
+
// inline <svg>: the add glyph is an icon component (astryx-icon on the
|
|
192
|
+
// svg) and remove reuses the registry `close` glyph (astryx-icon on its
|
|
193
|
+
// wrapping span). Assert the icon primitive is present and a glyph drew.
|
|
194
|
+
expect(addButton.querySelector('.astryx-icon')).toBeInTheDocument();
|
|
195
|
+
expect(addButton.querySelector('svg')).toBeInTheDocument();
|
|
196
|
+
expect(removeButton.querySelector('.astryx-icon')).toBeInTheDocument();
|
|
197
|
+
expect(removeButton.querySelector('svg')).toBeInTheDocument();
|
|
192
198
|
|
|
193
199
|
const selectedList = screen.getByRole('list', {
|
|
194
200
|
name: 'Selected columns',
|
|
@@ -199,9 +205,7 @@ describe('TransferList', () => {
|
|
|
199
205
|
'button',
|
|
200
206
|
);
|
|
201
207
|
expect(rowButtons[0]).toHaveAccessibleName('Reorder Name');
|
|
202
|
-
expect(rowButtons[0].querySelector('
|
|
203
|
-
'lucide-grip-vertical',
|
|
204
|
-
);
|
|
208
|
+
expect(rowButtons[0].querySelector('.astryx-icon')).toBeInTheDocument();
|
|
205
209
|
expect(rowButtons[1]).toHaveAccessibleName('Remove Name');
|
|
206
210
|
});
|
|
207
211
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file TransferList.tsx
|
|
7
|
-
* @input Controlled option data, React DOM portals, local action glyphs,
|
|
7
|
+
* @input Controlled option data, React DOM portals, local action glyphs, shared Lab reorder styles, and the core i18n catalog
|
|
8
8
|
* @output Exports TransferList with immediate transfers, vertical reordering, and responsive scroll ownership
|
|
9
9
|
* @position Lab collection input; consumed by index.ts, TransferListSelector, docs, and tests
|
|
10
10
|
*
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
27
27
|
type PointerEvent as ReactPointerEvent,
|
|
28
28
|
type ReactNode,
|
|
29
|
+
type SVGProps,
|
|
29
30
|
} from 'react';
|
|
30
31
|
import * as stylex from '@stylexjs/stylex';
|
|
31
32
|
import {flushSync} from 'react-dom';
|
|
@@ -40,9 +41,11 @@ import {Text} from '@astryxdesign/core/Text';
|
|
|
40
41
|
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
41
42
|
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
|
|
42
43
|
import {useAnnounce} from '@astryxdesign/core/hooks';
|
|
44
|
+
import {useTranslator} from '@astryxdesign/core/i18n';
|
|
43
45
|
import {
|
|
44
46
|
borderVars,
|
|
45
47
|
colorVars,
|
|
48
|
+
sizeVars,
|
|
46
49
|
spacingVars,
|
|
47
50
|
typeScaleVars,
|
|
48
51
|
} from '@astryxdesign/core/theme/tokens.stylex';
|
|
@@ -55,57 +58,36 @@ import {
|
|
|
55
58
|
import {reorderStyles} from '../reorderStyles';
|
|
56
59
|
import {transferListVars} from './tokens.stylex';
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
// Lucide glyphs the shared icon registry does not carry (there is no `add` or
|
|
62
|
+
// `grip` semantic name). Typed as icon components and rendered through `Icon`
|
|
63
|
+
// so sizing, colour and a11y come from the icon pipeline rather than a raw
|
|
64
|
+
// inline `<svg>`. Removal reuses the registry `close` glyph instead.
|
|
65
|
+
function PlusIcon(props: SVGProps<SVGSVGElement>) {
|
|
59
66
|
return (
|
|
60
67
|
<svg
|
|
61
|
-
className="lucide lucide-plus"
|
|
62
|
-
width={16}
|
|
63
|
-
height={16}
|
|
64
68
|
viewBox="0 0 24 24"
|
|
65
69
|
fill="none"
|
|
66
70
|
stroke="currentColor"
|
|
67
71
|
strokeWidth={1.5}
|
|
68
72
|
strokeLinecap="round"
|
|
69
73
|
strokeLinejoin="round"
|
|
70
|
-
|
|
74
|
+
{...props}>
|
|
71
75
|
<path d="M5 12h14" />
|
|
72
76
|
<path d="M12 5v14" />
|
|
73
77
|
</svg>
|
|
74
78
|
);
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
function
|
|
81
|
+
function GripVerticalIcon(props: SVGProps<SVGSVGElement>) {
|
|
78
82
|
return (
|
|
79
83
|
<svg
|
|
80
|
-
className="lucide lucide-x"
|
|
81
|
-
width={16}
|
|
82
|
-
height={16}
|
|
83
84
|
viewBox="0 0 24 24"
|
|
84
85
|
fill="none"
|
|
85
86
|
stroke="currentColor"
|
|
86
87
|
strokeWidth={1.5}
|
|
87
88
|
strokeLinecap="round"
|
|
88
89
|
strokeLinejoin="round"
|
|
89
|
-
|
|
90
|
-
<path d="M18 6 6 18" />
|
|
91
|
-
<path d="m6 6 12 12" />
|
|
92
|
-
</svg>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function GripVerticalIcon() {
|
|
97
|
-
return (
|
|
98
|
-
<svg
|
|
99
|
-
className="lucide lucide-grip-vertical"
|
|
100
|
-
width={16}
|
|
101
|
-
height={16}
|
|
102
|
-
viewBox="0 0 24 24"
|
|
103
|
-
fill="none"
|
|
104
|
-
stroke="currentColor"
|
|
105
|
-
strokeWidth={1.5}
|
|
106
|
-
strokeLinecap="round"
|
|
107
|
-
strokeLinejoin="round"
|
|
108
|
-
aria-hidden>
|
|
90
|
+
{...props}>
|
|
109
91
|
<circle cx="9" cy="5" r="1" />
|
|
110
92
|
<circle cx="9" cy="12" r="1" />
|
|
111
93
|
<circle cx="9" cy="19" r="1" />
|
|
@@ -275,7 +257,13 @@ const styles = stylex.create({
|
|
|
275
257
|
borderBlockEndColor: colorVars['--color-border'],
|
|
276
258
|
},
|
|
277
259
|
headerAction: {
|
|
260
|
+
// A text-link treatment, but the hit target still has to clear the WCAG
|
|
261
|
+
// 2.5.8 24px floor. The link keeps `height: auto` so its baseline aligns
|
|
262
|
+
// with the panel heading; `minBlockSize` gives the button a >=24px target
|
|
263
|
+
// (the sm element height) without changing where the text sits, and the
|
|
264
|
+
// width already exceeds 24px.
|
|
278
265
|
height: 'auto',
|
|
266
|
+
minBlockSize: sizeVars['--size-element-sm'],
|
|
279
267
|
paddingBlock: 0,
|
|
280
268
|
paddingInline: 0,
|
|
281
269
|
borderRadius: 0,
|
|
@@ -364,6 +352,9 @@ function moveItem<T>(
|
|
|
364
352
|
return nextValue;
|
|
365
353
|
}
|
|
366
354
|
|
|
355
|
+
// Case folding is locale-independent here, matching Selector, MultiSelector,
|
|
356
|
+
// and CommandPalette. Locale-sensitive folding would mean raw Intl access,
|
|
357
|
+
// which the shipped packages route through the provider instead.
|
|
367
358
|
function matchesQuery<T extends string>(
|
|
368
359
|
option: TransferListOption<T>,
|
|
369
360
|
query: string,
|
|
@@ -374,14 +365,10 @@ function matchesQuery<T extends string>(
|
|
|
374
365
|
const description =
|
|
375
366
|
typeof option.description === 'string' ? option.description : '';
|
|
376
367
|
return [option.label, description, option.group ?? ''].some(part =>
|
|
377
|
-
part.
|
|
368
|
+
part.toLowerCase().includes(query),
|
|
378
369
|
);
|
|
379
370
|
}
|
|
380
371
|
|
|
381
|
-
function itemCount(count: number): string {
|
|
382
|
-
return `${count} ${count === 1 ? 'item' : 'items'}`;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
372
|
/**
|
|
386
373
|
* A controlled dual-panel collection input for choosing and ordering values.
|
|
387
374
|
* Selection, ordering, drafts, persistence, and saved views stay explicit in
|
|
@@ -405,18 +392,18 @@ export function TransferList<T extends string = string>({
|
|
|
405
392
|
options,
|
|
406
393
|
value,
|
|
407
394
|
onChange,
|
|
408
|
-
selectedLabel
|
|
409
|
-
availableLabel
|
|
395
|
+
selectedLabel: selectedLabelFromProps,
|
|
396
|
+
availableLabel: availableLabelFromProps,
|
|
410
397
|
hasSearch = false,
|
|
411
398
|
searchLabel,
|
|
412
|
-
searchPlaceholder
|
|
399
|
+
searchPlaceholder: searchPlaceholderFromProps,
|
|
413
400
|
isReorderable = true,
|
|
414
401
|
hasSelectAll = false,
|
|
415
402
|
hasClear = false,
|
|
416
403
|
renderOption,
|
|
417
|
-
selectedEmptyText
|
|
418
|
-
availableEmptyText
|
|
419
|
-
noResultsText
|
|
404
|
+
selectedEmptyText: selectedEmptyTextFromProps,
|
|
405
|
+
availableEmptyText: availableEmptyTextFromProps,
|
|
406
|
+
noResultsText: noResultsTextFromProps,
|
|
420
407
|
xstyle,
|
|
421
408
|
className,
|
|
422
409
|
style,
|
|
@@ -426,6 +413,19 @@ export function TransferList<T extends string = string>({
|
|
|
426
413
|
'aria-describedby': ariaDescribedBy,
|
|
427
414
|
...restProps
|
|
428
415
|
}: TransferListProps<T>): ReactNode {
|
|
416
|
+
const t = useTranslator();
|
|
417
|
+
const selectedLabel =
|
|
418
|
+
selectedLabelFromProps ?? t('@astryx.transferList.selectedLabel');
|
|
419
|
+
const availableLabel =
|
|
420
|
+
availableLabelFromProps ?? t('@astryx.transferList.availableLabel');
|
|
421
|
+
const searchPlaceholder =
|
|
422
|
+
searchPlaceholderFromProps ?? t('@astryx.transferList.searchPlaceholder');
|
|
423
|
+
const selectedEmptyText =
|
|
424
|
+
selectedEmptyTextFromProps ?? t('@astryx.transferList.selectedEmpty');
|
|
425
|
+
const availableEmptyText =
|
|
426
|
+
availableEmptyTextFromProps ?? t('@astryx.transferList.availableEmpty');
|
|
427
|
+
const noResultsText =
|
|
428
|
+
noResultsTextFromProps ?? t('@astryx.transferList.noResults');
|
|
429
429
|
const labelId = useId();
|
|
430
430
|
const descriptionId = useId();
|
|
431
431
|
const selectedHeadingId = useId();
|
|
@@ -466,7 +466,7 @@ export function TransferList<T extends string = string>({
|
|
|
466
466
|
return map;
|
|
467
467
|
}, [options]);
|
|
468
468
|
const selectedValues = useMemo(() => new Set(value), [value]);
|
|
469
|
-
const normalizedQuery = query.trim().
|
|
469
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
470
470
|
const selectedOptions = useMemo(
|
|
471
471
|
() =>
|
|
472
472
|
value
|
|
@@ -603,11 +603,14 @@ export function TransferList<T extends string = string>({
|
|
|
603
603
|
const nextValue = [...currentValueRef.current, option.value];
|
|
604
604
|
commit(nextValue);
|
|
605
605
|
announce(
|
|
606
|
-
|
|
606
|
+
t('@astryx.transferList.announceAdded', {
|
|
607
|
+
label: option.label,
|
|
608
|
+
count: nextValue.length,
|
|
609
|
+
}),
|
|
607
610
|
);
|
|
608
611
|
focusAfterTransfer('available', index);
|
|
609
612
|
},
|
|
610
|
-
[announce, commit, focusAfterTransfer],
|
|
613
|
+
[announce, commit, focusAfterTransfer, t],
|
|
611
614
|
);
|
|
612
615
|
|
|
613
616
|
const removeOption = useCallback(
|
|
@@ -620,11 +623,14 @@ export function TransferList<T extends string = string>({
|
|
|
620
623
|
);
|
|
621
624
|
commit(nextValue);
|
|
622
625
|
announce(
|
|
623
|
-
|
|
626
|
+
t('@astryx.transferList.announceRemoved', {
|
|
627
|
+
label: option.label,
|
|
628
|
+
count: nextValue.length,
|
|
629
|
+
}),
|
|
624
630
|
);
|
|
625
631
|
focusAfterTransfer('selected', index);
|
|
626
632
|
},
|
|
627
|
-
[announce, commit, focusAfterTransfer],
|
|
633
|
+
[announce, commit, focusAfterTransfer, t],
|
|
628
634
|
);
|
|
629
635
|
|
|
630
636
|
const addAll = useCallback(() => {
|
|
@@ -637,9 +643,13 @@ export function TransferList<T extends string = string>({
|
|
|
637
643
|
.map(option => option.value);
|
|
638
644
|
if (additions.length > 0) {
|
|
639
645
|
commit([...currentValueRef.current, ...additions]);
|
|
640
|
-
announce(
|
|
646
|
+
announce(
|
|
647
|
+
t('@astryx.transferList.announceBulkAdded', {
|
|
648
|
+
count: additions.length,
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
641
651
|
}
|
|
642
|
-
}, [announce, commit, options]);
|
|
652
|
+
}, [announce, commit, options, t]);
|
|
643
653
|
|
|
644
654
|
const clearSelected = useCallback(() => {
|
|
645
655
|
const nextValue = currentValueRef.current.filter(optionValue => {
|
|
@@ -649,9 +659,9 @@ export function TransferList<T extends string = string>({
|
|
|
649
659
|
const removed = currentValueRef.current.length - nextValue.length;
|
|
650
660
|
if (removed > 0) {
|
|
651
661
|
commit(nextValue);
|
|
652
|
-
announce(
|
|
662
|
+
announce(t('@astryx.transferList.announceBulkRemoved', {count: removed}));
|
|
653
663
|
}
|
|
654
|
-
}, [announce, commit, optionByValue]);
|
|
664
|
+
}, [announce, commit, optionByValue, t]);
|
|
655
665
|
|
|
656
666
|
const movableRange = useCallback(
|
|
657
667
|
(optionValue: T, orderedValue: readonly T[]) => {
|
|
@@ -721,11 +731,15 @@ export function TransferList<T extends string = string>({
|
|
|
721
731
|
});
|
|
722
732
|
if (mode === 'keyboard') {
|
|
723
733
|
announce(
|
|
724
|
-
|
|
734
|
+
t('@astryx.transferList.announceGrabbed', {
|
|
735
|
+
label: option.label,
|
|
736
|
+
position: index + 1,
|
|
737
|
+
total: currentValueRef.current.length,
|
|
738
|
+
}),
|
|
725
739
|
);
|
|
726
740
|
}
|
|
727
741
|
},
|
|
728
|
-
[announce, isReorderable, setReorderSession],
|
|
742
|
+
[announce, isReorderable, setReorderSession, t],
|
|
729
743
|
);
|
|
730
744
|
|
|
731
745
|
const finishReorder = useCallback(
|
|
@@ -738,7 +752,11 @@ export function TransferList<T extends string = string>({
|
|
|
738
752
|
if (cancelled || !session.hasPointerMoved) {
|
|
739
753
|
setReorderSession(null);
|
|
740
754
|
if (cancelled) {
|
|
741
|
-
announce(
|
|
755
|
+
announce(
|
|
756
|
+
t('@astryx.transferList.announceMoveCancelled', {
|
|
757
|
+
label: session.label,
|
|
758
|
+
}),
|
|
759
|
+
);
|
|
742
760
|
}
|
|
743
761
|
return;
|
|
744
762
|
}
|
|
@@ -752,27 +770,42 @@ export function TransferList<T extends string = string>({
|
|
|
752
770
|
if (hasChanged) {
|
|
753
771
|
commit(nextValue);
|
|
754
772
|
announce(
|
|
755
|
-
|
|
773
|
+
t('@astryx.transferList.announceDropped', {
|
|
774
|
+
label: session.label,
|
|
775
|
+
position: session.toIndex + 1,
|
|
776
|
+
total: session.originalValue.length,
|
|
777
|
+
}),
|
|
756
778
|
);
|
|
757
779
|
} else {
|
|
758
780
|
announce(
|
|
759
|
-
|
|
781
|
+
t('@astryx.transferList.announceReturned', {
|
|
782
|
+
label: session.label,
|
|
783
|
+
position: session.fromIndex + 1,
|
|
784
|
+
}),
|
|
760
785
|
);
|
|
761
786
|
}
|
|
762
787
|
return;
|
|
763
788
|
}
|
|
764
789
|
if (cancelled) {
|
|
765
790
|
commit(session.originalValue);
|
|
766
|
-
announce(
|
|
791
|
+
announce(
|
|
792
|
+
t('@astryx.transferList.announceMoveCancelled', {
|
|
793
|
+
label: session.label,
|
|
794
|
+
}),
|
|
795
|
+
);
|
|
767
796
|
} else {
|
|
768
797
|
const index = currentValueRef.current.indexOf(session.value);
|
|
769
798
|
announce(
|
|
770
|
-
|
|
799
|
+
t('@astryx.transferList.announceDropped', {
|
|
800
|
+
label: session.label,
|
|
801
|
+
position: index + 1,
|
|
802
|
+
total: currentValueRef.current.length,
|
|
803
|
+
}),
|
|
771
804
|
);
|
|
772
805
|
}
|
|
773
806
|
setReorderSession(null);
|
|
774
807
|
},
|
|
775
|
-
[announce, commit, setReorderSession],
|
|
808
|
+
[announce, commit, setReorderSession, t],
|
|
776
809
|
);
|
|
777
810
|
|
|
778
811
|
const handleReorderClick = useCallback(
|
|
@@ -827,11 +860,15 @@ export function TransferList<T extends string = string>({
|
|
|
827
860
|
if (moveOption(option.value, target)) {
|
|
828
861
|
const nextIndex = currentValueRef.current.indexOf(option.value);
|
|
829
862
|
announce(
|
|
830
|
-
|
|
863
|
+
t('@astryx.transferList.announceMovedToPosition', {
|
|
864
|
+
label: option.label,
|
|
865
|
+
position: nextIndex + 1,
|
|
866
|
+
total: currentValueRef.current.length,
|
|
867
|
+
}),
|
|
831
868
|
);
|
|
832
869
|
}
|
|
833
870
|
},
|
|
834
|
-
[announce, finishReorder, movableRange, moveOption],
|
|
871
|
+
[announce, finishReorder, movableRange, moveOption, t],
|
|
835
872
|
);
|
|
836
873
|
|
|
837
874
|
const handlePointerDown = useCallback(
|
|
@@ -922,11 +959,15 @@ export function TransferList<T extends string = string>({
|
|
|
922
959
|
setReorderSession(nextSession);
|
|
923
960
|
if (hasCrossedThreshold && session.toIndex !== targetIndex) {
|
|
924
961
|
announce(
|
|
925
|
-
|
|
962
|
+
t('@astryx.transferList.announceMovedToPosition', {
|
|
963
|
+
label: option.label,
|
|
964
|
+
position: targetIndex + 1,
|
|
965
|
+
total: session.originalValue.length,
|
|
966
|
+
}),
|
|
926
967
|
);
|
|
927
968
|
}
|
|
928
969
|
},
|
|
929
|
-
[announce, movableRange, setReorderSession],
|
|
970
|
+
[announce, movableRange, setReorderSession, t],
|
|
930
971
|
);
|
|
931
972
|
|
|
932
973
|
const handlePointerEnd = useCallback(
|
|
@@ -956,17 +997,17 @@ export function TransferList<T extends string = string>({
|
|
|
956
997
|
const handleSearch = useCallback(
|
|
957
998
|
(nextQuery: string) => {
|
|
958
999
|
setQuery(nextQuery);
|
|
959
|
-
const normalized = nextQuery.trim().
|
|
1000
|
+
const normalized = nextQuery.trim().toLowerCase();
|
|
960
1001
|
if (normalized === '') {
|
|
961
1002
|
announce('');
|
|
962
1003
|
} else {
|
|
963
1004
|
const count = options.filter(option =>
|
|
964
1005
|
matchesQuery(option, normalized),
|
|
965
1006
|
).length;
|
|
966
|
-
announce(
|
|
1007
|
+
announce(t('@astryx.transferList.announceSearchResults', {count}));
|
|
967
1008
|
}
|
|
968
1009
|
},
|
|
969
|
-
[announce, options],
|
|
1010
|
+
[announce, options, t],
|
|
970
1011
|
);
|
|
971
1012
|
|
|
972
1013
|
const optionActions = (
|
|
@@ -976,12 +1017,13 @@ export function TransferList<T extends string = string>({
|
|
|
976
1017
|
) => {
|
|
977
1018
|
const isTransferDisabled = option.isTransferDisabled === true;
|
|
978
1019
|
const disabledReason =
|
|
979
|
-
option.disabledMessage ??
|
|
1020
|
+
option.disabledMessage ??
|
|
1021
|
+
t('@astryx.transferList.transferDisabled', {label: option.label});
|
|
980
1022
|
if (side === 'available') {
|
|
981
1023
|
return (
|
|
982
1024
|
<IconButton
|
|
983
|
-
label={
|
|
984
|
-
icon={<PlusIcon />}
|
|
1025
|
+
label={t('@astryx.transferList.addOption', {label: option.label})}
|
|
1026
|
+
icon={<Icon icon={PlusIcon} size="sm" />}
|
|
985
1027
|
size="sm"
|
|
986
1028
|
variant="ghost"
|
|
987
1029
|
isDisabled={isTransferDisabled}
|
|
@@ -993,8 +1035,8 @@ export function TransferList<T extends string = string>({
|
|
|
993
1035
|
}
|
|
994
1036
|
return (
|
|
995
1037
|
<IconButton
|
|
996
|
-
label={
|
|
997
|
-
icon={<
|
|
1038
|
+
label={t('@astryx.transferList.removeOption', {label: option.label})}
|
|
1039
|
+
icon={<Icon icon="close" size="sm" />}
|
|
998
1040
|
size="sm"
|
|
999
1041
|
variant="ghost"
|
|
1000
1042
|
isDisabled={isTransferDisabled}
|
|
@@ -1034,13 +1076,14 @@ export function TransferList<T extends string = string>({
|
|
|
1034
1076
|
const active = reorderSession?.value === option.value;
|
|
1035
1077
|
const isReorderDisabled = option.isReorderDisabled === true;
|
|
1036
1078
|
const disabledReason =
|
|
1037
|
-
option.disabledMessage ??
|
|
1079
|
+
option.disabledMessage ??
|
|
1080
|
+
t('@astryx.transferList.reorderDisabled', {label: option.label});
|
|
1038
1081
|
return (
|
|
1039
1082
|
<IconButton
|
|
1040
|
-
label={
|
|
1083
|
+
label={t('@astryx.transferList.reorderOption', {label: option.label})}
|
|
1041
1084
|
aria-describedby={reorderInstructionsId}
|
|
1042
1085
|
aria-pressed={active}
|
|
1043
|
-
icon={<GripVerticalIcon />}
|
|
1086
|
+
icon={<Icon icon={GripVerticalIcon} size="sm" />}
|
|
1044
1087
|
size="sm"
|
|
1045
1088
|
variant="ghost"
|
|
1046
1089
|
isDisabled={isReorderDisabled}
|
|
@@ -1164,7 +1207,9 @@ export function TransferList<T extends string = string>({
|
|
|
1164
1207
|
<TextInput
|
|
1165
1208
|
ref={searchRef}
|
|
1166
1209
|
role="searchbox"
|
|
1167
|
-
label={
|
|
1210
|
+
label={
|
|
1211
|
+
searchLabel ?? t('@astryx.transferList.searchLabel', {label})
|
|
1212
|
+
}
|
|
1168
1213
|
isLabelHidden
|
|
1169
1214
|
value={query}
|
|
1170
1215
|
placeholder={searchPlaceholder}
|
|
@@ -1178,8 +1223,7 @@ export function TransferList<T extends string = string>({
|
|
|
1178
1223
|
)}
|
|
1179
1224
|
|
|
1180
1225
|
<VisuallyHidden id={reorderInstructionsId}>
|
|
1181
|
-
|
|
1182
|
-
or End to move it. Press Space or Enter to drop, or Escape to cancel.
|
|
1226
|
+
{t('@astryx.transferList.reorderInstructions')}
|
|
1183
1227
|
</VisuallyHidden>
|
|
1184
1228
|
|
|
1185
1229
|
<div
|
|
@@ -1202,7 +1246,7 @@ export function TransferList<T extends string = string>({
|
|
|
1202
1246
|
</Text>
|
|
1203
1247
|
{hasClear && (
|
|
1204
1248
|
<Button
|
|
1205
|
-
label=
|
|
1249
|
+
label={t('@astryx.transferList.clear')}
|
|
1206
1250
|
size="sm"
|
|
1207
1251
|
variant="ghost"
|
|
1208
1252
|
xstyle={styles.headerAction}
|
|
@@ -1259,7 +1303,7 @@ export function TransferList<T extends string = string>({
|
|
|
1259
1303
|
</Text>
|
|
1260
1304
|
{hasSelectAll && (
|
|
1261
1305
|
<Button
|
|
1262
|
-
label=
|
|
1306
|
+
label={t('@astryx.transferList.addAll')}
|
|
1263
1307
|
size="sm"
|
|
1264
1308
|
variant="ghost"
|
|
1265
1309
|
xstyle={styles.headerAction}
|
|
@@ -1289,7 +1333,7 @@ export function TransferList<T extends string = string>({
|
|
|
1289
1333
|
role="presentation"
|
|
1290
1334
|
{...stylex.props(styles.groupHeading)}>
|
|
1291
1335
|
<Text type="body" weight="bold" color="primary">
|
|
1292
|
-
{group || '
|
|
1336
|
+
{group || t('@astryx.transferList.ungroupedLabel')}
|
|
1293
1337
|
</Text>
|
|
1294
1338
|
</li>
|
|
1295
1339
|
)}
|
|
@@ -420,7 +420,7 @@ describe('TransferListSelector', () => {
|
|
|
420
420
|
).toHaveTextContent('3 selected');
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
-
it('disables list controls
|
|
423
|
+
it('disables list controls when disabled, but never when merely busy', async () => {
|
|
424
424
|
const view = render(
|
|
425
425
|
<TransferListSelector
|
|
426
426
|
label="Visible fields"
|
|
@@ -477,7 +477,10 @@ describe('TransferListSelector', () => {
|
|
|
477
477
|
await waitFor(() => {
|
|
478
478
|
expect(dialog.querySelector('[aria-busy="true"]')).not.toBeNull();
|
|
479
479
|
});
|
|
480
|
-
|
|
480
|
+
// A pending change is announced with aria-busy, never by disabling the
|
|
481
|
+
// fieldset: `disabled` here would blur the row button the user just
|
|
482
|
+
// activated and drop focus to <body>.
|
|
483
|
+
expect(dialog.querySelector('fieldset')).toBeEnabled();
|
|
481
484
|
expect(
|
|
482
485
|
within(dialog).getByRole('button', {name: 'Cancel', hidden: true}),
|
|
483
486
|
).toBeEnabled();
|
|
@@ -486,6 +489,46 @@ describe('TransferListSelector', () => {
|
|
|
486
489
|
).toBeDisabled();
|
|
487
490
|
});
|
|
488
491
|
|
|
492
|
+
it('keeps focus on the activated row control while a change is pending', async () => {
|
|
493
|
+
const user = userEvent.setup();
|
|
494
|
+
const view = render(
|
|
495
|
+
<TransferListSelector
|
|
496
|
+
label="Visible fields"
|
|
497
|
+
options={OPTIONS}
|
|
498
|
+
value={['name']}
|
|
499
|
+
onChange={() => {}}
|
|
500
|
+
/>,
|
|
501
|
+
);
|
|
502
|
+
await openSelector();
|
|
503
|
+
const dialog = screen.getByRole('dialog', {
|
|
504
|
+
name: 'Visible fields',
|
|
505
|
+
...hidden,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const addStatus = within(dialog).getByRole('button', {
|
|
509
|
+
name: 'Add Status',
|
|
510
|
+
hidden: true,
|
|
511
|
+
});
|
|
512
|
+
await user.click(addStatus);
|
|
513
|
+
expect(addStatus).toHaveFocus();
|
|
514
|
+
|
|
515
|
+
view.rerender(
|
|
516
|
+
<TransferListSelector
|
|
517
|
+
label="Visible fields"
|
|
518
|
+
options={OPTIONS}
|
|
519
|
+
value={['name']}
|
|
520
|
+
onChange={() => {}}
|
|
521
|
+
isLoading
|
|
522
|
+
/>,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
await waitFor(() => {
|
|
526
|
+
expect(dialog.querySelector('[aria-busy="true"]')).not.toBeNull();
|
|
527
|
+
});
|
|
528
|
+
expect(addStatus).toHaveFocus();
|
|
529
|
+
expect(document.body).not.toHaveFocus();
|
|
530
|
+
});
|
|
531
|
+
|
|
489
532
|
it('forwards intentional selector shell and transfer-list content props', async () => {
|
|
490
533
|
render(
|
|
491
534
|
<TransferListSelector
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file TransferListSelector.tsx
|
|
7
|
-
* @input Controlled option data, uncontrolled ComplexSelector state, TransferList, and
|
|
7
|
+
* @input Controlled option data, uncontrolled ComplexSelector state, TransferList, action controls, and the core i18n catalog
|
|
8
8
|
* @output Exports the immediate-or-staged TransferListSelector composition with draft cleanup after dismissal
|
|
9
9
|
* @position Lab collection input shell; consumed by the TransferList entry point, docs, and tests
|
|
10
10
|
*
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ComplexSelector,
|
|
22
22
|
type ComplexSelectorProps,
|
|
23
23
|
} from '@astryxdesign/core/ComplexSelector';
|
|
24
|
+
import {useTranslator} from '@astryxdesign/core/i18n';
|
|
24
25
|
import {HStack} from '@astryxdesign/core/Stack';
|
|
25
26
|
import {borderVars, colorVars} from '@astryxdesign/core/theme/tokens.stylex';
|
|
26
27
|
import {TransferList, type TransferListProps} from './TransferList';
|
|
@@ -58,6 +59,16 @@ const styles = stylex.create({
|
|
|
58
59
|
overflowY: 'auto',
|
|
59
60
|
overscrollBehavior: 'contain',
|
|
60
61
|
},
|
|
62
|
+
// Busy is not disabled: `disabled` on this fieldset would blur the Add or
|
|
63
|
+
// Remove button the user just activated, dropping focus to <body>. The
|
|
64
|
+
// pending state is carried by aria-busy plus the trigger's spinner, so this
|
|
65
|
+
// only has to read as unavailable to the pointer.
|
|
66
|
+
fieldsetBusy: {
|
|
67
|
+
cursor: {
|
|
68
|
+
default: 'progress',
|
|
69
|
+
':is(:disabled,[aria-disabled="true"])': 'default',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
61
72
|
footer: {
|
|
62
73
|
flexShrink: 0,
|
|
63
74
|
borderBlockStartWidth: borderVars['--border-width'],
|
|
@@ -184,8 +195,8 @@ export function TransferListSelector<T extends string = string>({
|
|
|
184
195
|
isDisabled,
|
|
185
196
|
isLoading,
|
|
186
197
|
width,
|
|
187
|
-
applyLabel
|
|
188
|
-
cancelLabel
|
|
198
|
+
applyLabel: applyLabelFromProps,
|
|
199
|
+
cancelLabel: cancelLabelFromProps,
|
|
189
200
|
selectedLabel,
|
|
190
201
|
availableLabel,
|
|
191
202
|
hasSearch,
|
|
@@ -200,6 +211,11 @@ export function TransferListSelector<T extends string = string>({
|
|
|
200
211
|
noResultsText,
|
|
201
212
|
...selectorProps
|
|
202
213
|
}: TransferListSelectorProps<T>): ReactNode {
|
|
214
|
+
const t = useTranslator();
|
|
215
|
+
const applyLabel =
|
|
216
|
+
applyLabelFromProps ?? t('@astryx.transferListSelector.apply');
|
|
217
|
+
const cancelLabel =
|
|
218
|
+
cancelLabelFromProps ?? t('@astryx.transferListSelector.cancel');
|
|
203
219
|
const [draftValue, setDraftValue] = useState<readonly T[]>(value);
|
|
204
220
|
const previousAppliedValueRef = useRef<readonly T[]>([...value]);
|
|
205
221
|
const previousCommitBehaviorRef = useRef(commitBehavior);
|
|
@@ -236,7 +252,10 @@ export function TransferListSelector<T extends string = string>({
|
|
|
236
252
|
value={value}
|
|
237
253
|
onChange={onChange}
|
|
238
254
|
changeAction={changeAction}
|
|
239
|
-
triggerLabel={
|
|
255
|
+
triggerLabel={
|
|
256
|
+
triggerLabel ??
|
|
257
|
+
t('@astryx.transferListSelector.triggerLabel', {count: value.length})
|
|
258
|
+
}
|
|
240
259
|
isDisabled={isDisabled}
|
|
241
260
|
isLoading={isLoading}
|
|
242
261
|
width={resolvedWidth}
|
|
@@ -256,14 +275,23 @@ export function TransferListSelector<T extends string = string>({
|
|
|
256
275
|
/>
|
|
257
276
|
)}
|
|
258
277
|
<fieldset
|
|
259
|
-
disabled={isDisabled
|
|
260
|
-
{...stylex.props(
|
|
278
|
+
disabled={isDisabled}
|
|
279
|
+
{...stylex.props(
|
|
280
|
+
styles.fieldset,
|
|
281
|
+
state.isBusy && styles.fieldsetBusy,
|
|
282
|
+
)}>
|
|
261
283
|
<TransferList
|
|
262
284
|
label={label}
|
|
263
285
|
isLabelHidden
|
|
264
286
|
options={options}
|
|
265
287
|
value={transferListValue}
|
|
266
288
|
onChange={nextValue => {
|
|
289
|
+
// Now that busy no longer disables the fieldset, the controls
|
|
290
|
+
// stay live while a change is in flight; ignore transfers
|
|
291
|
+
// until it settles so one pending action cannot be stacked.
|
|
292
|
+
if (state.isBusy) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
267
295
|
if (isStaged) {
|
|
268
296
|
setDraftValue(nextValue);
|
|
269
297
|
} else {
|