@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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file TransferList.tsx
3
- * @input Controlled option data, React DOM portals, local action glyphs, and shared Lab reorder styles
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":"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"}
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, and shared Lab reorder styles
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
- "aria-hidden": true,
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 RemoveIcon() {
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
- "aria-hidden": true,
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.toLocaleLowerCase().includes(query));
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 = 'Selected',
224
- availableLabel = 'Available',
206
+ selectedLabel: selectedLabelFromProps,
207
+ availableLabel: availableLabelFromProps,
225
208
  hasSearch = false,
226
209
  searchLabel,
227
- searchPlaceholder = 'Search…',
210
+ searchPlaceholder: searchPlaceholderFromProps,
228
211
  isReorderable = true,
229
212
  hasSelectAll = false,
230
213
  hasClear = false,
231
214
  renderOption,
232
- selectedEmptyText = 'No selected options',
233
- availableEmptyText = 'No available options',
234
- noResultsText = 'No results',
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().toLocaleLowerCase();
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(`${option.label} added. ${itemCount(nextValue.length)} selected.`);
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(`${option.label} removed. ${itemCount(nextValue.length)} selected.`);
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(`${itemCount(additions.length)} added.`);
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(`${itemCount(removed)} removed.`);
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(`${option.label} picked up, position ${index + 1} of ${currentValueRef.current.length}. Use arrow keys to move, Space or Enter to drop, or Escape to cancel.`);
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(`${session.label} move cancelled.`);
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(`${session.label} dropped at position ${session.toIndex + 1} of ${session.originalValue.length}.`);
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(`${session.label} returned to position ${session.fromIndex + 1}.`);
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(`${session.label} move cancelled.`);
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(`${session.label} dropped at position ${index + 1} of ${currentValueRef.current.length}.`);
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(`${option.label}, position ${nextIndex + 1} of ${currentValueRef.current.length}.`);
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(`${option.label}, position ${targetIndex + 1} of ${session.originalValue.length}.`);
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().toLocaleLowerCase();
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(`${itemCount(count)} found.`);
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 ?? `${option.label} cannot be moved`;
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: `Add ${option.label}`,
647
- icon: /*#__PURE__*/_jsx(PlusIcon, {}),
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: `Remove ${option.label}`,
658
- icon: /*#__PURE__*/_jsx(RemoveIcon, {}),
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 ?? `${option.label} cannot be reordered`;
731
+ const disabledReason = option.disabledMessage ?? t('@astryx.transferList.reorderDisabled', {
732
+ label: option.label
733
+ });
691
734
  return /*#__PURE__*/_jsx(IconButton, {
692
- label: `Reorder ${option.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(GripVerticalIcon, {}),
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 ?? `Search ${label}`,
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: "Press Space or Enter to pick up an item. Use Arrow Up, Arrow Down, Home, or End to move it. Press Space or Enter to drop, or Escape to cancel."
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: "Clear",
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: "Add all",
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 || 'Other'
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 action controls
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;AAG5C,OAAO,EAAe,KAAK,iBAAiB,EAAC,MAAM,gBAAgB,CAAC;AA4CpE,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,UAAoB,EACpB,WAAsB,EACtB,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,CAoH1C;yBA/Ie,oBAAoB"}
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 action controls
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 = 'Apply',
98
- cancelLabel = 'Cancel',
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 ?? `${value.length} selected`,
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 || state.isBusy,
163
+ disabled: isDisabled,
158
164
  ...{
159
- className: "x1iyjqo2 xs83m0k xdl72j9 xeuugli x2lwn1j x1ghz6dp x1717udv xc342km xng3xce x1odjw0f xish69e"
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.7b60efa",
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.7b60efa",
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.7b60efa",
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.js",
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.js",
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
- expect(addButton.querySelector('svg')).toHaveClass('lucide-plus');
191
- expect(removeButton.querySelector('svg')).toHaveClass('lucide-x');
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('svg')).toHaveClass(
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, and shared Lab reorder styles
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
- function PlusIcon() {
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
- aria-hidden>
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 RemoveIcon() {
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
- aria-hidden>
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.toLocaleLowerCase().includes(query),
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 = 'Selected',
409
- availableLabel = 'Available',
395
+ selectedLabel: selectedLabelFromProps,
396
+ availableLabel: availableLabelFromProps,
410
397
  hasSearch = false,
411
398
  searchLabel,
412
- searchPlaceholder = 'Search…',
399
+ searchPlaceholder: searchPlaceholderFromProps,
413
400
  isReorderable = true,
414
401
  hasSelectAll = false,
415
402
  hasClear = false,
416
403
  renderOption,
417
- selectedEmptyText = 'No selected options',
418
- availableEmptyText = 'No available options',
419
- noResultsText = 'No results',
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().toLocaleLowerCase();
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
- `${option.label} added. ${itemCount(nextValue.length)} selected.`,
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
- `${option.label} removed. ${itemCount(nextValue.length)} selected.`,
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(`${itemCount(additions.length)} added.`);
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(`${itemCount(removed)} removed.`);
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
- `${option.label} picked up, position ${index + 1} of ${currentValueRef.current.length}. Use arrow keys to move, Space or Enter to drop, or Escape to cancel.`,
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(`${session.label} move cancelled.`);
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
- `${session.label} dropped at position ${session.toIndex + 1} of ${session.originalValue.length}.`,
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
- `${session.label} returned to position ${session.fromIndex + 1}.`,
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(`${session.label} move cancelled.`);
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
- `${session.label} dropped at position ${index + 1} of ${currentValueRef.current.length}.`,
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
- `${option.label}, position ${nextIndex + 1} of ${currentValueRef.current.length}.`,
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
- `${option.label}, position ${targetIndex + 1} of ${session.originalValue.length}.`,
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().toLocaleLowerCase();
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(`${itemCount(count)} found.`);
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 ?? `${option.label} cannot be moved`;
1020
+ option.disabledMessage ??
1021
+ t('@astryx.transferList.transferDisabled', {label: option.label});
980
1022
  if (side === 'available') {
981
1023
  return (
982
1024
  <IconButton
983
- label={`Add ${option.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={`Remove ${option.label}`}
997
- icon={<RemoveIcon />}
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 ?? `${option.label} cannot be reordered`;
1079
+ option.disabledMessage ??
1080
+ t('@astryx.transferList.reorderDisabled', {label: option.label});
1038
1081
  return (
1039
1082
  <IconButton
1040
- label={`Reorder ${option.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={searchLabel ?? `Search ${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
- Press Space or Enter to pick up an item. Use Arrow Up, Arrow Down, Home,
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="Clear"
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="Add all"
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 || 'Other'}
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 and Apply while keeping Cancel operable', async () => {
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
- expect(dialog.querySelector('fieldset')).toBeDisabled();
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 action controls
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 = 'Apply',
188
- cancelLabel = 'Cancel',
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={triggerLabel ?? `${value.length} selected`}
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 || state.isBusy}
260
- {...stylex.props(styles.fieldset)}>
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 {