string-list-input.tsx 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { X } from 'lucide-react';
  2. import { KeyboardEvent, useId, useRef, useState } from 'react';
  3. import { Badge } from '@/vdb/components/ui/badge.js';
  4. import { Input } from '@/vdb/components/ui/input.js';
  5. import type { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
  6. import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
  7. import { cn } from '@/vdb/lib/utils.js';
  8. import { useLingui } from '@lingui/react';
  9. export function StringListInput({
  10. value,
  11. onChange,
  12. onBlur,
  13. disabled,
  14. name,
  15. fieldDef,
  16. }: DashboardFormComponentProps) {
  17. const [inputValue, setInputValue] = useState('');
  18. const inputRef = useRef<HTMLInputElement>(null);
  19. const { i18n } = useLingui();
  20. const isDisabled = isReadonlyField(fieldDef) || disabled;
  21. const id = useId();
  22. const items = Array.isArray(value) ? value : [];
  23. const addItem = (item: string) => {
  24. const trimmedItem = item.trim();
  25. if (trimmedItem) {
  26. onChange([...items, trimmedItem]);
  27. setInputValue('');
  28. }
  29. };
  30. const removeItem = (indexToRemove: number) => {
  31. onChange(items.filter((_, index) => index !== indexToRemove));
  32. };
  33. const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
  34. if (e.key === 'Enter' || e.key === ',') {
  35. e.preventDefault();
  36. addItem(inputValue);
  37. } else if (e.key === 'Backspace' && !inputValue && items.length > 0) {
  38. // Remove last item when backspace is pressed on empty input
  39. removeItem(items.length - 1);
  40. }
  41. };
  42. const handleInputBlur = () => {
  43. // Add current input value as item on blur if there's any
  44. if (inputValue.trim()) {
  45. addItem(inputValue);
  46. }
  47. onBlur?.();
  48. };
  49. return (
  50. <div
  51. className={cn(
  52. 'flex min-h-10 w-full flex-wrap gap-2',
  53. isDisabled && 'cursor-not-allowed opacity-50',
  54. )}
  55. >
  56. {!isDisabled && (
  57. <Input
  58. ref={inputRef}
  59. type="text"
  60. value={inputValue}
  61. onChange={e => setInputValue(e.target.value)}
  62. onKeyDown={handleKeyDown}
  63. onBlur={handleInputBlur}
  64. name={name}
  65. placeholder={i18n.t('Type and press Enter or comma to add...')}
  66. className="min-w-[120px]"
  67. />
  68. )}
  69. <div className="flex flex-wrap gap-1 items-start justify-start">
  70. {items.map((item, index) => (
  71. <Badge key={id + index} variant="secondary">
  72. <span>{item}</span>
  73. {!isDisabled && (
  74. <button
  75. type="button"
  76. onClick={e => {
  77. e.stopPropagation();
  78. removeItem(index);
  79. }}
  80. className={cn(
  81. 'ml-1 rounded-full outline-none ring-offset-background',
  82. 'hover:bg-muted focus:ring-2 focus:ring-ring focus:ring-offset-2',
  83. )}
  84. aria-label={`Remove ${item}`}
  85. >
  86. <X className="h-3 w-3" />
  87. </button>
  88. )}
  89. </Badge>
  90. ))}
  91. </div>
  92. </div>
  93. );
  94. }
  95. StringListInput.metadata = {
  96. isListInput: true,
  97. };