@astryxdesign/lab 0.4.7-canary.fe95cd5 → 0.5.0-canary.055e75f

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":"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"}
@@ -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 { 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
31
  import { borderVars, colorVars, spacingVars, typeScaleVars } from '@astryxdesign/core/theme/tokens.stylex';
31
32
  import { focusOutlineStyles, mergeProps, mergeRefs, themeProps } from '@astryxdesign/core/utils';
@@ -186,15 +187,16 @@ function moveItem(value, fromIndex, toIndex) {
186
187
  nextValue.splice(toIndex, 0, item);
187
188
  return nextValue;
188
189
  }
190
+
191
+ // Case folding is locale-independent here, matching Selector, MultiSelector,
192
+ // and CommandPalette. Locale-sensitive folding would mean raw Intl access,
193
+ // which the shipped packages route through the provider instead.
189
194
  function matchesQuery(option, query) {
190
195
  if (query === '') {
191
196
  return true;
192
197
  }
193
198
  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'}`;
199
+ return [option.label, description, option.group ?? ''].some(part => part.toLowerCase().includes(query));
198
200
  }
199
201
 
200
202
  /**
@@ -220,18 +222,18 @@ export function TransferList({
220
222
  options,
221
223
  value,
222
224
  onChange,
223
- selectedLabel = 'Selected',
224
- availableLabel = 'Available',
225
+ selectedLabel: selectedLabelFromProps,
226
+ availableLabel: availableLabelFromProps,
225
227
  hasSearch = false,
226
228
  searchLabel,
227
- searchPlaceholder = 'Search…',
229
+ searchPlaceholder: searchPlaceholderFromProps,
228
230
  isReorderable = true,
229
231
  hasSelectAll = false,
230
232
  hasClear = false,
231
233
  renderOption,
232
- selectedEmptyText = 'No selected options',
233
- availableEmptyText = 'No available options',
234
- noResultsText = 'No results',
234
+ selectedEmptyText: selectedEmptyTextFromProps,
235
+ availableEmptyText: availableEmptyTextFromProps,
236
+ noResultsText: noResultsTextFromProps,
235
237
  xstyle,
236
238
  className,
237
239
  style,
@@ -241,6 +243,13 @@ export function TransferList({
241
243
  'aria-describedby': ariaDescribedBy,
242
244
  ...restProps
243
245
  }) {
246
+ const t = useTranslator();
247
+ const selectedLabel = selectedLabelFromProps ?? t('@astryx.transferList.selectedLabel');
248
+ const availableLabel = availableLabelFromProps ?? t('@astryx.transferList.availableLabel');
249
+ const searchPlaceholder = searchPlaceholderFromProps ?? t('@astryx.transferList.searchPlaceholder');
250
+ const selectedEmptyText = selectedEmptyTextFromProps ?? t('@astryx.transferList.selectedEmpty');
251
+ const availableEmptyText = availableEmptyTextFromProps ?? t('@astryx.transferList.availableEmpty');
252
+ const noResultsText = noResultsTextFromProps ?? t('@astryx.transferList.noResults');
244
253
  const labelId = useId();
245
254
  const descriptionId = useId();
246
255
  const selectedHeadingId = useId();
@@ -276,7 +285,7 @@ export function TransferList({
276
285
  return map;
277
286
  }, [options]);
278
287
  const selectedValues = useMemo(() => new Set(value), [value]);
279
- const normalizedQuery = query.trim().toLocaleLowerCase();
288
+ const normalizedQuery = query.trim().toLowerCase();
280
289
  const selectedOptions = useMemo(() => value.map(optionValue => optionByValue.get(optionValue)).filter(option => option != null && matchesQuery(option, normalizedQuery)), [normalizedQuery, optionByValue, value]);
281
290
  const availableOptions = useMemo(() => options.filter(option => !selectedValues.has(option.value) && matchesQuery(option, normalizedQuery)), [normalizedQuery, options, selectedValues]);
282
291
  const groupedAvailable = useMemo(() => {
@@ -371,25 +380,33 @@ export function TransferList({
371
380
  }
372
381
  const nextValue = [...currentValueRef.current, option.value];
373
382
  commit(nextValue);
374
- announce(`${option.label} added. ${itemCount(nextValue.length)} selected.`);
383
+ announce(t('@astryx.transferList.announceAdded', {
384
+ label: option.label,
385
+ count: nextValue.length
386
+ }));
375
387
  focusAfterTransfer('available', index);
376
- }, [announce, commit, focusAfterTransfer]);
388
+ }, [announce, commit, focusAfterTransfer, t]);
377
389
  const removeOption = useCallback((option, index) => {
378
390
  if (option.isTransferDisabled) {
379
391
  return;
380
392
  }
381
393
  const nextValue = currentValueRef.current.filter(item => item !== option.value);
382
394
  commit(nextValue);
383
- announce(`${option.label} removed. ${itemCount(nextValue.length)} selected.`);
395
+ announce(t('@astryx.transferList.announceRemoved', {
396
+ label: option.label,
397
+ count: nextValue.length
398
+ }));
384
399
  focusAfterTransfer('selected', index);
385
- }, [announce, commit, focusAfterTransfer]);
400
+ }, [announce, commit, focusAfterTransfer, t]);
386
401
  const addAll = useCallback(() => {
387
402
  const additions = options.filter(option => !option.isTransferDisabled && !currentValueRef.current.includes(option.value)).map(option => option.value);
388
403
  if (additions.length > 0) {
389
404
  commit([...currentValueRef.current, ...additions]);
390
- announce(`${itemCount(additions.length)} added.`);
405
+ announce(t('@astryx.transferList.announceBulkAdded', {
406
+ count: additions.length
407
+ }));
391
408
  }
392
- }, [announce, commit, options]);
409
+ }, [announce, commit, options, t]);
393
410
  const clearSelected = useCallback(() => {
394
411
  const nextValue = currentValueRef.current.filter(optionValue => {
395
412
  const option = optionByValue.get(optionValue);
@@ -398,9 +415,11 @@ export function TransferList({
398
415
  const removed = currentValueRef.current.length - nextValue.length;
399
416
  if (removed > 0) {
400
417
  commit(nextValue);
401
- announce(`${itemCount(removed)} removed.`);
418
+ announce(t('@astryx.transferList.announceBulkRemoved', {
419
+ count: removed
420
+ }));
402
421
  }
403
- }, [announce, commit, optionByValue]);
422
+ }, [announce, commit, optionByValue, t]);
404
423
  const movableRange = useCallback((optionValue, orderedValue) => {
405
424
  const index = orderedValue.indexOf(optionValue);
406
425
  let start = 0;
@@ -463,9 +482,13 @@ export function TransferList({
463
482
  hasPointerMoved: false
464
483
  });
465
484
  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.`);
485
+ announce(t('@astryx.transferList.announceGrabbed', {
486
+ label: option.label,
487
+ position: index + 1,
488
+ total: currentValueRef.current.length
489
+ }));
467
490
  }
468
- }, [announce, isReorderable, setReorderSession]);
491
+ }, [announce, isReorderable, setReorderSession, t]);
469
492
  const finishReorder = useCallback(cancelled => {
470
493
  const session = reorderSessionRef.current;
471
494
  if (!session) {
@@ -475,7 +498,9 @@ export function TransferList({
475
498
  if (cancelled || !session.hasPointerMoved) {
476
499
  setReorderSession(null);
477
500
  if (cancelled) {
478
- announce(`${session.label} move cancelled.`);
501
+ announce(t('@astryx.transferList.announceMoveCancelled', {
502
+ label: session.label
503
+ }));
479
504
  }
480
505
  return;
481
506
  }
@@ -484,21 +509,34 @@ export function TransferList({
484
509
  flushSync(() => setReorderSession(null));
485
510
  if (hasChanged) {
486
511
  commit(nextValue);
487
- announce(`${session.label} dropped at position ${session.toIndex + 1} of ${session.originalValue.length}.`);
512
+ announce(t('@astryx.transferList.announceDropped', {
513
+ label: session.label,
514
+ position: session.toIndex + 1,
515
+ total: session.originalValue.length
516
+ }));
488
517
  } else {
489
- announce(`${session.label} returned to position ${session.fromIndex + 1}.`);
518
+ announce(t('@astryx.transferList.announceReturned', {
519
+ label: session.label,
520
+ position: session.fromIndex + 1
521
+ }));
490
522
  }
491
523
  return;
492
524
  }
493
525
  if (cancelled) {
494
526
  commit(session.originalValue);
495
- announce(`${session.label} move cancelled.`);
527
+ announce(t('@astryx.transferList.announceMoveCancelled', {
528
+ label: session.label
529
+ }));
496
530
  } else {
497
531
  const index = currentValueRef.current.indexOf(session.value);
498
- announce(`${session.label} dropped at position ${index + 1} of ${currentValueRef.current.length}.`);
532
+ announce(t('@astryx.transferList.announceDropped', {
533
+ label: session.label,
534
+ position: index + 1,
535
+ total: currentValueRef.current.length
536
+ }));
499
537
  }
500
538
  setReorderSession(null);
501
- }, [announce, commit, setReorderSession]);
539
+ }, [announce, commit, setReorderSession, t]);
502
540
  const handleReorderClick = useCallback(option => {
503
541
  if (suppressClickRef.current) {
504
542
  suppressClickRef.current = false;
@@ -543,9 +581,13 @@ export function TransferList({
543
581
  event.preventDefault();
544
582
  if (moveOption(option.value, target)) {
545
583
  const nextIndex = currentValueRef.current.indexOf(option.value);
546
- announce(`${option.label}, position ${nextIndex + 1} of ${currentValueRef.current.length}.`);
584
+ announce(t('@astryx.transferList.announceMovedToPosition', {
585
+ label: option.label,
586
+ position: nextIndex + 1,
587
+ total: currentValueRef.current.length
588
+ }));
547
589
  }
548
- }, [announce, finishReorder, movableRange, moveOption]);
590
+ }, [announce, finishReorder, movableRange, moveOption, t]);
549
591
  const handlePointerDown = useCallback((event, option) => {
550
592
  if (event.button !== 0 || option.isReorderDisabled) {
551
593
  return;
@@ -608,9 +650,13 @@ export function TransferList({
608
650
  };
609
651
  setReorderSession(nextSession);
610
652
  if (hasCrossedThreshold && session.toIndex !== targetIndex) {
611
- announce(`${option.label}, position ${targetIndex + 1} of ${session.originalValue.length}.`);
653
+ announce(t('@astryx.transferList.announceMovedToPosition', {
654
+ label: option.label,
655
+ position: targetIndex + 1,
656
+ total: session.originalValue.length
657
+ }));
612
658
  }
613
- }, [announce, movableRange, setReorderSession]);
659
+ }, [announce, movableRange, setReorderSession, t]);
614
660
  const handlePointerEnd = useCallback((event, cancelled) => {
615
661
  const session = reorderSessionRef.current;
616
662
  if (session?.mode !== 'pointer' || session.pointerId !== event.pointerId) {
@@ -630,20 +676,26 @@ export function TransferList({
630
676
  }, [finishReorder]);
631
677
  const handleSearch = useCallback(nextQuery => {
632
678
  setQuery(nextQuery);
633
- const normalized = nextQuery.trim().toLocaleLowerCase();
679
+ const normalized = nextQuery.trim().toLowerCase();
634
680
  if (normalized === '') {
635
681
  announce('');
636
682
  } else {
637
683
  const count = options.filter(option => matchesQuery(option, normalized)).length;
638
- announce(`${itemCount(count)} found.`);
684
+ announce(t('@astryx.transferList.announceSearchResults', {
685
+ count
686
+ }));
639
687
  }
640
- }, [announce, options]);
688
+ }, [announce, options, t]);
641
689
  const optionActions = (option, side, index) => {
642
690
  const isTransferDisabled = option.isTransferDisabled === true;
643
- const disabledReason = option.disabledMessage ?? `${option.label} cannot be moved`;
691
+ const disabledReason = option.disabledMessage ?? t('@astryx.transferList.transferDisabled', {
692
+ label: option.label
693
+ });
644
694
  if (side === 'available') {
645
695
  return /*#__PURE__*/_jsx(IconButton, {
646
- label: `Add ${option.label}`,
696
+ label: t('@astryx.transferList.addOption', {
697
+ label: option.label
698
+ }),
647
699
  icon: /*#__PURE__*/_jsx(PlusIcon, {}),
648
700
  size: "sm",
649
701
  variant: "ghost",
@@ -654,7 +706,9 @@ export function TransferList({
654
706
  });
655
707
  }
656
708
  return /*#__PURE__*/_jsx(IconButton, {
657
- label: `Remove ${option.label}`,
709
+ label: t('@astryx.transferList.removeOption', {
710
+ label: option.label
711
+ }),
658
712
  icon: /*#__PURE__*/_jsx(RemoveIcon, {}),
659
713
  size: "sm",
660
714
  variant: "ghost",
@@ -687,9 +741,13 @@ export function TransferList({
687
741
  }
688
742
  const active = reorderSession?.value === option.value;
689
743
  const isReorderDisabled = option.isReorderDisabled === true;
690
- const disabledReason = option.disabledMessage ?? `${option.label} cannot be reordered`;
744
+ const disabledReason = option.disabledMessage ?? t('@astryx.transferList.reorderDisabled', {
745
+ label: option.label
746
+ });
691
747
  return /*#__PURE__*/_jsx(IconButton, {
692
- label: `Reorder ${option.label}`,
748
+ label: t('@astryx.transferList.reorderOption', {
749
+ label: option.label
750
+ }),
693
751
  "aria-describedby": reorderInstructionsId,
694
752
  "aria-pressed": active,
695
753
  icon: /*#__PURE__*/_jsx(GripVerticalIcon, {}),
@@ -778,7 +836,9 @@ export function TransferList({
778
836
  children: /*#__PURE__*/_jsx(TextInput, {
779
837
  ref: searchRef,
780
838
  role: "searchbox",
781
- label: searchLabel ?? `Search ${label}`,
839
+ label: searchLabel ?? t('@astryx.transferList.searchLabel', {
840
+ label
841
+ }),
782
842
  isLabelHidden: true,
783
843
  value: query,
784
844
  placeholder: searchPlaceholder,
@@ -794,7 +854,7 @@ export function TransferList({
794
854
  })
795
855
  }), /*#__PURE__*/_jsx(VisuallyHidden, {
796
856
  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."
857
+ children: t('@astryx.transferList.reorderInstructions')
798
858
  }), /*#__PURE__*/_jsxs("div", {
799
859
  ...mergeProps(themeProps('transfer-list-collection'), {
800
860
  className: "xrvj5dj xcjjqqt x1sp3fdr xxhr3t xeuugli"
@@ -817,7 +877,7 @@ export function TransferList({
817
877
  color: "secondary",
818
878
  children: selectedLabel
819
879
  }), hasClear && /*#__PURE__*/_jsx(Button, {
820
- label: "Clear",
880
+ label: t('@astryx.transferList.clear'),
821
881
  size: "sm",
822
882
  variant: "ghost",
823
883
  xstyle: styles.headerAction,
@@ -866,7 +926,7 @@ export function TransferList({
866
926
  color: "secondary",
867
927
  children: availableLabel
868
928
  }), hasSelectAll && /*#__PURE__*/_jsx(Button, {
869
- label: "Add all",
929
+ label: t('@astryx.transferList.addAll'),
870
930
  size: "sm",
871
931
  variant: "ghost",
872
932
  xstyle: styles.headerAction,
@@ -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}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.4.7-canary.fe95cd5",
3
+ "version": "0.5.0-canary.055e75f",
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.4.7-canary.fe95cd5",
48
+ "@astryxdesign/core": "0.5.0-canary.055e75f",
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.4.7-canary.fe95cd5",
58
+ "@astryxdesign/cli": "0.5.0-canary.055e75f",
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",
@@ -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
  *
@@ -40,6 +40,7 @@ import {Text} from '@astryxdesign/core/Text';
40
40
  import {TextInput} from '@astryxdesign/core/TextInput';
41
41
  import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
42
42
  import {useAnnounce} from '@astryxdesign/core/hooks';
43
+ import {useTranslator} from '@astryxdesign/core/i18n';
43
44
  import {
44
45
  borderVars,
45
46
  colorVars,
@@ -364,6 +365,9 @@ function moveItem<T>(
364
365
  return nextValue;
365
366
  }
366
367
 
368
+ // Case folding is locale-independent here, matching Selector, MultiSelector,
369
+ // and CommandPalette. Locale-sensitive folding would mean raw Intl access,
370
+ // which the shipped packages route through the provider instead.
367
371
  function matchesQuery<T extends string>(
368
372
  option: TransferListOption<T>,
369
373
  query: string,
@@ -374,14 +378,10 @@ function matchesQuery<T extends string>(
374
378
  const description =
375
379
  typeof option.description === 'string' ? option.description : '';
376
380
  return [option.label, description, option.group ?? ''].some(part =>
377
- part.toLocaleLowerCase().includes(query),
381
+ part.toLowerCase().includes(query),
378
382
  );
379
383
  }
380
384
 
381
- function itemCount(count: number): string {
382
- return `${count} ${count === 1 ? 'item' : 'items'}`;
383
- }
384
-
385
385
  /**
386
386
  * A controlled dual-panel collection input for choosing and ordering values.
387
387
  * Selection, ordering, drafts, persistence, and saved views stay explicit in
@@ -405,18 +405,18 @@ export function TransferList<T extends string = string>({
405
405
  options,
406
406
  value,
407
407
  onChange,
408
- selectedLabel = 'Selected',
409
- availableLabel = 'Available',
408
+ selectedLabel: selectedLabelFromProps,
409
+ availableLabel: availableLabelFromProps,
410
410
  hasSearch = false,
411
411
  searchLabel,
412
- searchPlaceholder = 'Search…',
412
+ searchPlaceholder: searchPlaceholderFromProps,
413
413
  isReorderable = true,
414
414
  hasSelectAll = false,
415
415
  hasClear = false,
416
416
  renderOption,
417
- selectedEmptyText = 'No selected options',
418
- availableEmptyText = 'No available options',
419
- noResultsText = 'No results',
417
+ selectedEmptyText: selectedEmptyTextFromProps,
418
+ availableEmptyText: availableEmptyTextFromProps,
419
+ noResultsText: noResultsTextFromProps,
420
420
  xstyle,
421
421
  className,
422
422
  style,
@@ -426,6 +426,19 @@ export function TransferList<T extends string = string>({
426
426
  'aria-describedby': ariaDescribedBy,
427
427
  ...restProps
428
428
  }: TransferListProps<T>): ReactNode {
429
+ const t = useTranslator();
430
+ const selectedLabel =
431
+ selectedLabelFromProps ?? t('@astryx.transferList.selectedLabel');
432
+ const availableLabel =
433
+ availableLabelFromProps ?? t('@astryx.transferList.availableLabel');
434
+ const searchPlaceholder =
435
+ searchPlaceholderFromProps ?? t('@astryx.transferList.searchPlaceholder');
436
+ const selectedEmptyText =
437
+ selectedEmptyTextFromProps ?? t('@astryx.transferList.selectedEmpty');
438
+ const availableEmptyText =
439
+ availableEmptyTextFromProps ?? t('@astryx.transferList.availableEmpty');
440
+ const noResultsText =
441
+ noResultsTextFromProps ?? t('@astryx.transferList.noResults');
429
442
  const labelId = useId();
430
443
  const descriptionId = useId();
431
444
  const selectedHeadingId = useId();
@@ -466,7 +479,7 @@ export function TransferList<T extends string = string>({
466
479
  return map;
467
480
  }, [options]);
468
481
  const selectedValues = useMemo(() => new Set(value), [value]);
469
- const normalizedQuery = query.trim().toLocaleLowerCase();
482
+ const normalizedQuery = query.trim().toLowerCase();
470
483
  const selectedOptions = useMemo(
471
484
  () =>
472
485
  value
@@ -603,11 +616,14 @@ export function TransferList<T extends string = string>({
603
616
  const nextValue = [...currentValueRef.current, option.value];
604
617
  commit(nextValue);
605
618
  announce(
606
- `${option.label} added. ${itemCount(nextValue.length)} selected.`,
619
+ t('@astryx.transferList.announceAdded', {
620
+ label: option.label,
621
+ count: nextValue.length,
622
+ }),
607
623
  );
608
624
  focusAfterTransfer('available', index);
609
625
  },
610
- [announce, commit, focusAfterTransfer],
626
+ [announce, commit, focusAfterTransfer, t],
611
627
  );
612
628
 
613
629
  const removeOption = useCallback(
@@ -620,11 +636,14 @@ export function TransferList<T extends string = string>({
620
636
  );
621
637
  commit(nextValue);
622
638
  announce(
623
- `${option.label} removed. ${itemCount(nextValue.length)} selected.`,
639
+ t('@astryx.transferList.announceRemoved', {
640
+ label: option.label,
641
+ count: nextValue.length,
642
+ }),
624
643
  );
625
644
  focusAfterTransfer('selected', index);
626
645
  },
627
- [announce, commit, focusAfterTransfer],
646
+ [announce, commit, focusAfterTransfer, t],
628
647
  );
629
648
 
630
649
  const addAll = useCallback(() => {
@@ -637,9 +656,13 @@ export function TransferList<T extends string = string>({
637
656
  .map(option => option.value);
638
657
  if (additions.length > 0) {
639
658
  commit([...currentValueRef.current, ...additions]);
640
- announce(`${itemCount(additions.length)} added.`);
659
+ announce(
660
+ t('@astryx.transferList.announceBulkAdded', {
661
+ count: additions.length,
662
+ }),
663
+ );
641
664
  }
642
- }, [announce, commit, options]);
665
+ }, [announce, commit, options, t]);
643
666
 
644
667
  const clearSelected = useCallback(() => {
645
668
  const nextValue = currentValueRef.current.filter(optionValue => {
@@ -649,9 +672,9 @@ export function TransferList<T extends string = string>({
649
672
  const removed = currentValueRef.current.length - nextValue.length;
650
673
  if (removed > 0) {
651
674
  commit(nextValue);
652
- announce(`${itemCount(removed)} removed.`);
675
+ announce(t('@astryx.transferList.announceBulkRemoved', {count: removed}));
653
676
  }
654
- }, [announce, commit, optionByValue]);
677
+ }, [announce, commit, optionByValue, t]);
655
678
 
656
679
  const movableRange = useCallback(
657
680
  (optionValue: T, orderedValue: readonly T[]) => {
@@ -721,11 +744,15 @@ export function TransferList<T extends string = string>({
721
744
  });
722
745
  if (mode === 'keyboard') {
723
746
  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.`,
747
+ t('@astryx.transferList.announceGrabbed', {
748
+ label: option.label,
749
+ position: index + 1,
750
+ total: currentValueRef.current.length,
751
+ }),
725
752
  );
726
753
  }
727
754
  },
728
- [announce, isReorderable, setReorderSession],
755
+ [announce, isReorderable, setReorderSession, t],
729
756
  );
730
757
 
731
758
  const finishReorder = useCallback(
@@ -738,7 +765,11 @@ export function TransferList<T extends string = string>({
738
765
  if (cancelled || !session.hasPointerMoved) {
739
766
  setReorderSession(null);
740
767
  if (cancelled) {
741
- announce(`${session.label} move cancelled.`);
768
+ announce(
769
+ t('@astryx.transferList.announceMoveCancelled', {
770
+ label: session.label,
771
+ }),
772
+ );
742
773
  }
743
774
  return;
744
775
  }
@@ -752,27 +783,42 @@ export function TransferList<T extends string = string>({
752
783
  if (hasChanged) {
753
784
  commit(nextValue);
754
785
  announce(
755
- `${session.label} dropped at position ${session.toIndex + 1} of ${session.originalValue.length}.`,
786
+ t('@astryx.transferList.announceDropped', {
787
+ label: session.label,
788
+ position: session.toIndex + 1,
789
+ total: session.originalValue.length,
790
+ }),
756
791
  );
757
792
  } else {
758
793
  announce(
759
- `${session.label} returned to position ${session.fromIndex + 1}.`,
794
+ t('@astryx.transferList.announceReturned', {
795
+ label: session.label,
796
+ position: session.fromIndex + 1,
797
+ }),
760
798
  );
761
799
  }
762
800
  return;
763
801
  }
764
802
  if (cancelled) {
765
803
  commit(session.originalValue);
766
- announce(`${session.label} move cancelled.`);
804
+ announce(
805
+ t('@astryx.transferList.announceMoveCancelled', {
806
+ label: session.label,
807
+ }),
808
+ );
767
809
  } else {
768
810
  const index = currentValueRef.current.indexOf(session.value);
769
811
  announce(
770
- `${session.label} dropped at position ${index + 1} of ${currentValueRef.current.length}.`,
812
+ t('@astryx.transferList.announceDropped', {
813
+ label: session.label,
814
+ position: index + 1,
815
+ total: currentValueRef.current.length,
816
+ }),
771
817
  );
772
818
  }
773
819
  setReorderSession(null);
774
820
  },
775
- [announce, commit, setReorderSession],
821
+ [announce, commit, setReorderSession, t],
776
822
  );
777
823
 
778
824
  const handleReorderClick = useCallback(
@@ -827,11 +873,15 @@ export function TransferList<T extends string = string>({
827
873
  if (moveOption(option.value, target)) {
828
874
  const nextIndex = currentValueRef.current.indexOf(option.value);
829
875
  announce(
830
- `${option.label}, position ${nextIndex + 1} of ${currentValueRef.current.length}.`,
876
+ t('@astryx.transferList.announceMovedToPosition', {
877
+ label: option.label,
878
+ position: nextIndex + 1,
879
+ total: currentValueRef.current.length,
880
+ }),
831
881
  );
832
882
  }
833
883
  },
834
- [announce, finishReorder, movableRange, moveOption],
884
+ [announce, finishReorder, movableRange, moveOption, t],
835
885
  );
836
886
 
837
887
  const handlePointerDown = useCallback(
@@ -922,11 +972,15 @@ export function TransferList<T extends string = string>({
922
972
  setReorderSession(nextSession);
923
973
  if (hasCrossedThreshold && session.toIndex !== targetIndex) {
924
974
  announce(
925
- `${option.label}, position ${targetIndex + 1} of ${session.originalValue.length}.`,
975
+ t('@astryx.transferList.announceMovedToPosition', {
976
+ label: option.label,
977
+ position: targetIndex + 1,
978
+ total: session.originalValue.length,
979
+ }),
926
980
  );
927
981
  }
928
982
  },
929
- [announce, movableRange, setReorderSession],
983
+ [announce, movableRange, setReorderSession, t],
930
984
  );
931
985
 
932
986
  const handlePointerEnd = useCallback(
@@ -956,17 +1010,17 @@ export function TransferList<T extends string = string>({
956
1010
  const handleSearch = useCallback(
957
1011
  (nextQuery: string) => {
958
1012
  setQuery(nextQuery);
959
- const normalized = nextQuery.trim().toLocaleLowerCase();
1013
+ const normalized = nextQuery.trim().toLowerCase();
960
1014
  if (normalized === '') {
961
1015
  announce('');
962
1016
  } else {
963
1017
  const count = options.filter(option =>
964
1018
  matchesQuery(option, normalized),
965
1019
  ).length;
966
- announce(`${itemCount(count)} found.`);
1020
+ announce(t('@astryx.transferList.announceSearchResults', {count}));
967
1021
  }
968
1022
  },
969
- [announce, options],
1023
+ [announce, options, t],
970
1024
  );
971
1025
 
972
1026
  const optionActions = (
@@ -976,11 +1030,12 @@ export function TransferList<T extends string = string>({
976
1030
  ) => {
977
1031
  const isTransferDisabled = option.isTransferDisabled === true;
978
1032
  const disabledReason =
979
- option.disabledMessage ?? `${option.label} cannot be moved`;
1033
+ option.disabledMessage ??
1034
+ t('@astryx.transferList.transferDisabled', {label: option.label});
980
1035
  if (side === 'available') {
981
1036
  return (
982
1037
  <IconButton
983
- label={`Add ${option.label}`}
1038
+ label={t('@astryx.transferList.addOption', {label: option.label})}
984
1039
  icon={<PlusIcon />}
985
1040
  size="sm"
986
1041
  variant="ghost"
@@ -993,7 +1048,7 @@ export function TransferList<T extends string = string>({
993
1048
  }
994
1049
  return (
995
1050
  <IconButton
996
- label={`Remove ${option.label}`}
1051
+ label={t('@astryx.transferList.removeOption', {label: option.label})}
997
1052
  icon={<RemoveIcon />}
998
1053
  size="sm"
999
1054
  variant="ghost"
@@ -1034,10 +1089,11 @@ export function TransferList<T extends string = string>({
1034
1089
  const active = reorderSession?.value === option.value;
1035
1090
  const isReorderDisabled = option.isReorderDisabled === true;
1036
1091
  const disabledReason =
1037
- option.disabledMessage ?? `${option.label} cannot be reordered`;
1092
+ option.disabledMessage ??
1093
+ t('@astryx.transferList.reorderDisabled', {label: option.label});
1038
1094
  return (
1039
1095
  <IconButton
1040
- label={`Reorder ${option.label}`}
1096
+ label={t('@astryx.transferList.reorderOption', {label: option.label})}
1041
1097
  aria-describedby={reorderInstructionsId}
1042
1098
  aria-pressed={active}
1043
1099
  icon={<GripVerticalIcon />}
@@ -1164,7 +1220,9 @@ export function TransferList<T extends string = string>({
1164
1220
  <TextInput
1165
1221
  ref={searchRef}
1166
1222
  role="searchbox"
1167
- label={searchLabel ?? `Search ${label}`}
1223
+ label={
1224
+ searchLabel ?? t('@astryx.transferList.searchLabel', {label})
1225
+ }
1168
1226
  isLabelHidden
1169
1227
  value={query}
1170
1228
  placeholder={searchPlaceholder}
@@ -1178,8 +1236,7 @@ export function TransferList<T extends string = string>({
1178
1236
  )}
1179
1237
 
1180
1238
  <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.
1239
+ {t('@astryx.transferList.reorderInstructions')}
1183
1240
  </VisuallyHidden>
1184
1241
 
1185
1242
  <div
@@ -1202,7 +1259,7 @@ export function TransferList<T extends string = string>({
1202
1259
  </Text>
1203
1260
  {hasClear && (
1204
1261
  <Button
1205
- label="Clear"
1262
+ label={t('@astryx.transferList.clear')}
1206
1263
  size="sm"
1207
1264
  variant="ghost"
1208
1265
  xstyle={styles.headerAction}
@@ -1259,7 +1316,7 @@ export function TransferList<T extends string = string>({
1259
1316
  </Text>
1260
1317
  {hasSelectAll && (
1261
1318
  <Button
1262
- label="Add all"
1319
+ label={t('@astryx.transferList.addAll')}
1263
1320
  size="sm"
1264
1321
  variant="ghost"
1265
1322
  xstyle={styles.headerAction}
@@ -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 {